Compare commits

..

17 Commits

Author SHA1 Message Date
Codex e9b268a5e3 docs(cli): fix loop init flag example
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-02 09:17:39 +00:00
Claude 1c8d8f0ff6 feat(cli): add micro loop to scaffold a self-improving repo loop
`micro loop init` writes an autonomous improvement loop into any repository —
the same planner/builder/triage loop that maintains go-micro, generalized:

- planner (loop-planner.yml): keeps a ranked queue in .github/loop/PRIORITIES.md
- builder (loop-builder.yml): builds the top open item as a single-concern PR,
  auto-merged on green CI
- triage (loop-triage.yml): turns CI failures into scoped fix issues

plus .github/loop/NORTH_STAR.md (direction) and PRIORITIES.md (queue).

The agent adapter is mention-based, not hardcoded to Codex: `--agent @codex`
(or any @mention agent that responds on an issue and can run gh), `--token-secret`,
`--branch`, `--ci-workflow`, and cron flags are the whole config-vs-core boundary.
Templates use << >> delimiters so GitHub Actions' own ${{ }} expressions pass
through untouched. `micro loop verify` checks the wiring and flags the two things
the CLI can't: the token secret and branch protection (the green-CI gate).

Built inside go-micro with the config/core split already drawn, so the workflows
can later be extracted to a standalone reusable-workflows repo without a rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL
2026-07-02 09:07:19 +00:00
Asim Aslam a24eaad1c9 docs: roll changelog for v6.3.12 (#3646)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 09:16:05 +01:00
Asim Aslam 357fdf2777 docs: surface first agent on-ramp (#3644)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:52:09 +01:00
Asim Aslam a66fb4ff2c docs(priorities): refresh architect adoption queue (#3641)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:16:31 +01:00
Asim Aslam 8e3ba68d58 loop-release: fix 403 on tag push (checkout token clobbered the PAT) (#3638)
goreleaser / goreleaser (push) Waiting to run
* docs: complete Ollama provider surface (capability matrix, README, example fixes)

Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* loop-release: don't let checkout's persisted GITHUB_TOKEN clobber the PAT push

The daily release job computed the next tag correctly but the tag push 403'd:
"Permission to micro/go-micro.git denied to github-actions[bot]" (run
28554612450). Cause: actions/checkout persists the default GITHUB_TOKEN as an
http.extraheader Authorization credential for github.com, which git sends on
ALL requests to that host — including our manual
`git push https://x-access-token:${PAT}@github.com/...`. The persisted header
overrides the URL-embedded PAT, so the push authenticates as
github-actions[bot], which can't push tags (the job only grants
contents: read).

Set persist-credentials: false so no extraheader is written and the PAT in the
push URL is the only credential used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:51:31 +01:00
Asim Aslam 6e04f1afb5 docs: complete Ollama provider surface (capability matrix, README, example fixes) (#3637)
Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:44:58 +01:00
YongSoo Park 110cb44d41 feat: add Ollama provider with local and cloud support (#3636)
Add a dedicated Ollama AI provider (ai/ollama/) that auto-detects
local vs cloud mode based on the base URL:

- Local Ollama: native /api/chat endpoint with NDJSON streaming
- Ollama Cloud: OpenAI-compatible /v1/chat/completions with SSE streaming

Both modes support tool calls with a multi-round execution loop.

Add agent.BaseURL option so agents can point at non-default LLM
endpoints (e.g. local Ollama, proxies). Wire it through micro.AgentBaseURL
at the top level.

Include a complete example (examples/agent-ollama/) demonstrating a
knowledge-base service with auto-discovered tools, a custom time tool,
streaming, and env-var configuration for local vs cloud.

Closes #3632
2026-07-02 07:37:36 +01:00
Asim Aslam f06e7467ce ci: smoke test installer first-run CLI (#3635)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 07:00:45 +01:00
Asim Aslam 412491568f docs(priorities): refresh architect adoption queue (#3631)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 06:28:17 +01:00
Asim Aslam d2e1520a14 Verify zero-to-hero reference app in harness (#3628)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:56:41 +01:00
Asim Aslam 0a18ac6c15 docs(priorities): refresh architect queue (#3624)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:15:41 +01:00
Asim Aslam 45a23a3417 test first-agent walkthrough boundaries (#3621)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 03:31:30 +01:00
Asim Aslam ae239b0102 docs(priorities): refresh architect queue (#3619)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:53:21 +01:00
Asim Aslam 3a2d21f1ac agent: dedupe plan delegate tool side effects (#3616)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:35:18 +01:00
Asim Aslam 40a559e8db Harden universe notify finalization (#3612)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 01:08:12 +01:00
Asim Aslam 28320fc1d4 cli: add first-agent preflight diagnostics (#3608)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 00:02:53 +01:00
39 changed files with 2366 additions and 39 deletions
+8
View File
@@ -29,6 +29,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
# Do NOT persist the default GITHUB_TOKEN as a git credential.
# actions/checkout otherwise sets an http.extraheader Authorization
# for github.com that is sent on ALL pushes — including our manual
# PAT push below — and overrides the PAT, so the tag push
# authenticates as github-actions[bot] and 403s (the job only has
# contents: read). With this off, the PAT embedded in the push URL
# is the only credential. (Fixes run 28554612450.)
persist-credentials: false
- name: Cut the next patch release if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
+1
View File
@@ -62,6 +62,7 @@ examples/mcp/hello/hello
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
+8
View File
@@ -17,6 +17,14 @@ next version when it ships.
## [Unreleased]
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows for a planner (keeps a ranked queue), builder (builds the top item as a single-concern PR, auto-merged on green CI), and triage (turns CI failures into fix issues), dispatched to an @mention-driven coding agent. `micro loop init` writes the workflows + `NORTH_STAR`/`PRIORITIES`; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
---
## [6.3.12] - July 2026
### Added
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
+8 -1
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -19,6 +19,7 @@ help:
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@@ -48,11 +49,17 @@ test-coverage:
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
./internal/harness/install-smoke/run.sh
# Run the shared provider conformance contract with the deterministic mock
# provider. This is the no-secret path used by CI and local dogfooding to keep
# provider-facing agent/tool semantics covered on every machine.
+26 -3
View File
@@ -25,6 +25,7 @@ Running Go Micro in production, or building on it and want help? Paid **support,
## Contents
- [Quick Start](#quick-start)
- [First agent on-ramp](#first-agent-on-ramp)
- [Why an Agent Harness](#why-an-agent-harness)
- [Writing Services](#writing-services)
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
@@ -66,14 +67,35 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This scaffold → run → call path is covered by the no-secret CI harness. To run
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
verify just the local installer and first-run CLI boundaries without network
access or provider keys, use:
```bash
make install-smoke
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### First agent on-ramp
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
2. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro agent inspect`, run history, memory, and provider checks when the first
conversation does something unexpected.
3. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
### Generate from a prompt — with an LLM key
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
@@ -308,7 +330,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
| MCP gateway | Every endpoint is an AI tool automatically |
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -405,6 +427,7 @@ Swap providers with a single import — same interface everywhere:
| Mistral | `mistral-large-latest` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| Ollama | `llama3.2` (local) |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
+4
View File
@@ -34,6 +34,7 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
@@ -149,6 +150,9 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
+35 -1
View File
@@ -290,7 +290,11 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
if call.Name == toolPlan {
return a.handlePlan(call)
}
return next(ctx, call)
res := next(ctx, call)
if res.Refused == "" && toolErrorMessage(res) == "" {
a.completeNextPlanStep()
}
return res
}
}
@@ -368,6 +372,36 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
}
func (a *agentImpl) completeNextPlanStep() {
plan := a.loadPlan()
if plan == "" {
return
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return
}
steps, ok := data["steps"].([]any)
if !ok {
return
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status == "" || status == "pending" || status == "in_progress" {
step["status"] = "done"
b, err := json.Marshal(data)
if err == nil {
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
}
return
}
}
}
// handleHumanInput records that the model needs operator input before it can continue.
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
prompt, _ := call.Input["prompt"].(string)
+39
View File
@@ -102,6 +102,45 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
toolRuns := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "pending"},
},
}})
for i := 0; i < 3; i++ {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
if res.Content != "created Design" {
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
}
}
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created Design", nil
}))
if _, err := a.Ask(ctx, "create Design once"); err != nil {
t.Fatalf("Ask: %v", err)
}
if toolRuns != 1 {
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
}
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
t.Fatalf("plan = %s, want completed action marked done", plan)
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
+7
View File
@@ -40,6 +40,7 @@ type Options struct {
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
@@ -168,6 +169,12 @@ func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// BaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// Address sets the network address for the agent's service endpoint.
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
// port and avoid advertising the default service address.
+729
View File
@@ -0,0 +1,729 @@
// Package ollama implements the Ollama model provider.
//
// Ollama runs open-weight models locally (or via Ollama Cloud). This
// provider supports two API styles:
//
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
//
// The provider auto-detects which style to use based on the base URL.
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
//
// Usage (local):
//
// import _ "go-micro.dev/v6/ai/ollama"
//
// m := ai.New("ollama",
// ai.WithBaseURL("http://localhost:11434"),
// ai.WithModel("llama3.2"),
// )
//
// Usage (Ollama Cloud):
//
// m := ai.New("ollama",
// ai.WithBaseURL("https://ollama.com/v1"),
// ai.WithAPIKey("your-key"),
// ai.WithModel("gpt-oss:120b"),
// )
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
type Provider struct {
opts ai.Options
// cloudOverride forces cloud mode for testing. When true, the provider
// uses the OpenAI-compatible endpoint regardless of the base URL.
cloudOverride bool
}
// NewProvider creates a new Ollama provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama3.2"
}
if options.BaseURL == "" {
options.BaseURL = "http://localhost:11434"
}
return &Provider{opts: options}
}
// Init initializes the provider with options.
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
// Options returns the provider options.
func (p *Provider) Options() ai.Options { return p.opts }
// String returns the provider name.
func (p *Provider) String() string { return "ollama" }
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
// the native /api/chat.
func (p *Provider) isCloud() bool {
if p.cloudOverride {
return true
}
return strings.Contains(p.opts.BaseURL, "ollama.com")
}
// chatPath returns the API endpoint path for chat completions.
func (p *Provider) chatPath() string {
if p.isCloud() {
return "/v1/chat/completions"
}
return "/api/chat"
}
// streamPath returns the API endpoint path for streaming chat.
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
// Local Ollama uses /api/chat with stream:true.
func (p *Provider) streamPath() string {
return p.chatPath()
}
// Generate generates a response from the Ollama model.
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if p.isCloud() {
return p.generateOpenAI(ctx, req)
}
return p.generateNative(ctx, req)
}
// Stream generates a streaming response.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if p.isCloud() {
return p.streamOpenAI(ctx, req)
}
return p.streamNative(ctx, req)
}
// ---------------------------------------------------------------------------
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
// ---------------------------------------------------------------------------
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
if err != nil {
return nil, err
}
// No tool calls or no handler — return as-is.
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop.
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
"tool_calls": rawMsg.toolCalls,
})
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"tool_call_id": pendingCalls[i].ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["max_tokens"] = p.opts.MaxTokens
}
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
"tool_calls": followUpRaw.toolCalls,
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{
Reply: choice.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.Usage.PromptTokens,
OutputTokens: chatResp.Usage.CompletionTokens,
TotalTokens: chatResp.Usage.TotalTokens,
},
}
var rawToolCalls []map[string]any
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: choice.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
func buildOpenAIMessages(req *ai.Request) []map[string]any {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
return messages
}
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
type sseStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *sseStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *sseStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: localhost:11434/api/chat)
// ---------------------------------------------------------------------------
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
resp, rawMsg, err := p.callNative(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
})
if len(rawMsg.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
}
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
})
if len(followUpRaw.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
}
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{
Reply: chatResp.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.PromptEvalCount,
OutputTokens: chatResp.EvalCount,
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
},
}
var rawToolCalls []map[string]any
for _, tc := range chatResp.Message.ToolCalls {
var input map[string]any
switch v := tc.Function.Arguments.(type) {
case string:
if err := json.Unmarshal([]byte(v), &input); err != nil {
input = map[string]any{}
}
case map[string]any:
input = v
default:
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: chatResp.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
type ndjsonStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *ndjsonStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Done {
return nil, io.EOF
}
if chunk.Message.Content != "" {
return &ai.Response{Reply: chunk.Message.Content}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *ndjsonStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// rawChatMessage holds the raw assistant content and tool calls for
// follow-up messages.
type rawChatMessage struct {
content string
toolCalls []map[string]any
}
+333
View File
@@ -0,0 +1,333 @@
package ollama
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
// ---------------------------------------------------------------------------
// Provider basics
// ---------------------------------------------------------------------------
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "ollama" {
t.Errorf("Expected 'ollama', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "llama3.2" {
t.Errorf("Expected default model 'llama3.2', got '%s'", opts.Model)
}
if opts.BaseURL != "http://localhost:11434" {
t.Errorf("Expected default base URL 'http://localhost:11434', got '%s'", opts.BaseURL)
}
}
func TestProvider_IsCloud(t *testing.T) {
local := NewProvider(ai.WithBaseURL("http://localhost:11434"))
if local.isCloud() {
t.Error("localhost should not be cloud")
}
cloud := NewProvider(ai.WithBaseURL("https://ollama.com/v1"))
if !cloud.isCloud() {
t.Error("ollama.com should be cloud")
}
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: /api/chat)
// ---------------------------------------------------------------------------
func TestNative_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("Expected /api/chat, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "Hello from local Ollama!"},
"done": true,
"prompt_eval_count": 10,
"eval_count": 5
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from local Ollama!" {
t.Errorf("Expected 'Hello from local Ollama!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestNative_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Seoul\"}"}}]
},
"done": true
}`))
} else {
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "The weather in Seoul is sunny."},
"done": true
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "get_weather" {
t.Errorf("Expected tool 'get_weather', got '%s'", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"temp": 22, "condition": "sunny"}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("llama3.2"),
ai.WithToolHandler(handler),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "What's the weather?",
Tools: []ai.Tool{{
Name: "get_weather",
Description: "Get weather",
Properties: map[string]any{"city": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "The weather in Seoul is sunny." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestNative_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":{"role":"assistant","content":"Hello"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":" world"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":""},"done":true}` + "\n"))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello world" {
t.Errorf("Expected 'Hello world', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Cloud mode (Ollama Cloud: /v1/chat/completions)
// ---------------------------------------------------------------------------
func TestCloud_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
"choices": [{"message": {"role": "assistant", "content": "Hello from Ollama Cloud!"}}]
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("gemma4:31b-cloud"), ai.WithAPIKey("test-key"))
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from Ollama Cloud!" {
t.Errorf("Expected 'Hello from Ollama Cloud!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestCloud_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call_1", "function": {"name": "search", "arguments": "{\"query\":\"go interfaces\"}"}}]
}}]
}`))
} else {
w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "Go interfaces are implicit."}}]
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"results": ["Go interfaces are implicit"]}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
ai.WithToolHandler(handler),
)
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Search for Go interfaces",
Tools: []ai.Tool{{
Name: "search",
Description: "Search the knowledge base",
Properties: map[string]any{"query": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "Go interfaces are implicit." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestCloud_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" cloud\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
)
p.cloudOverride = true
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello cloud" {
t.Errorf("Expected 'Hello cloud', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
func TestProvider_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "model not found"}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("nonexistent"))
_, err := p.Generate(context.Background(), &ai.Request{Prompt: "Hi"})
if err == nil {
t.Error("Expected error on API failure")
}
if !strings.Contains(err.Error(), "API error") {
t.Errorf("Expected 'API error' in message, got '%s'", err.Error())
}
}
+46
View File
@@ -625,3 +625,49 @@ Scopes provide fine-grained access control over which tokens can call which serv
The gateway's scope system uses `auth.Account` from the go-micro framework. Scopes on accounts are the same `[]string` field used by the framework's `auth.Rules` and `wrapper/auth` package. The gateway stores scope requirements in the default store under `endpoint-scopes/<service>.<endpoint>` keys and checks them on every HTTP request.
For service-level (RPC) auth within the go-micro mesh, use the `wrapper/auth` package which provides `auth.Rules` with priority-based access control. See the [auth wrapper documentation](../../wrapper/auth/README.md) for details.
## Self-improving loop (`micro loop`)
Turn a repository into a self-improving one: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage — gated by CI. This is the
same loop that maintains go-micro itself, generalized so any repo (and any
@mention-driven agent) can use it.
```bash
micro loop init # scaffold the loop into the current repo
micro loop verify # check a repo is wired correctly
```
`micro loop init` writes three workflows and a queue:
| Role | File | What it does |
|------|------|--------------|
| Planner | `.github/workflows/loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
| Builder | `.github/workflows/loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
| Triage | `.github/workflows/loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
Direction lives in `.github/loop/NORTH_STAR.md` — edit it to steer the loop.
Common flags:
```bash
micro loop init \
--agent @codex \
--token-secret LOOP_TOKEN \
--branch main \
--ci-workflow CI
```
- `--agent`: how the workflows summon the agent (an `@mention`)
- `--token-secret`: repo secret holding the driving user PAT
- `--branch`: base branch for the loop's PRs
- `--ci-workflow`: `name:` of the CI workflow triage watches
Two things the CLI can't do for you (and `micro loop verify` reminds you of):
1. **Add the token secret.** The agent ignores `@mentions` from the
`github-actions` bot, so dispatch posts as a real user via a PAT stored in
the `--token-secret` repo secret. The workflows no-op until it's set.
2. **Set branch protection.** Require the CI checks with **0 approving reviews**
so the builder's native auto-merge lands PRs the moment CI is green — that
green-CI gate is the loop's only safety mechanism, so keep the suite strong.
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
"github.com/urfave/cli/v2"
microcmd "go-micro.dev/v6/cmd"
)
func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
commands := map[string]bool{}
subcommands := map[string]map[string]bool{}
for _, command := range microcmd.DefaultCmd.App().Commands {
commands[command.Name] = true
for _, subcommand := range command.Subcommands {
if subcommands[command.Name] == nil {
subcommands[command.Name] = map[string]bool{}
}
subcommands[command.Name][subcommand.Name] = true
}
}
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
if !commands[want] {
t.Fatalf("first-agent walkthrough missing %q command", want)
}
}
if !subcommands["agent"]["preflight"] {
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
}
if !subcommands["inspect"]["agent"] {
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
}
chat := commandByName(t, "chat")
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
}
}
func commandByName(t *testing.T, name string) *cli.Command {
t.Helper()
for _, command := range microcmd.DefaultCmd.App().Commands {
if command.Name == name {
return command
}
}
t.Fatalf("missing command %q", name)
return nil
}
+300
View File
@@ -0,0 +1,300 @@
// Package loop implements the 'micro loop' command, which scaffolds and
// verifies an autonomous improvement loop for a repository.
//
// The loop is a set of GitHub Actions workflows — a planner that keeps a ranked
// queue, a builder that builds the top item as a single-concern PR, and a triage
// pass that turns CI failures into fix issues — that dispatch a coding agent by
// @mention on a fresh tracking issue each run. `micro loop init` writes those
// workflows (plus a NORTH_STAR and PRIORITIES queue) into a repo; `micro loop
// verify` checks that a repo is wired correctly.
package loop
import (
"bytes"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
//go:embed templates/*
var templatesFS embed.FS
// config is the substitution surface for the workflow templates. It is the
// whole "config vs core" boundary: the workflows are the reusable core, these
// fields are what a given repo tunes.
type config struct {
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // name: of the CI workflow triage watches for failures
PlannerCron string // cron for the planner
BuilderCron string // cron for the builder
}
// generated workflow files: template name -> destination (relative to repo root).
var workflows = map[string]string{
"templates/loop-planner.yml.tmpl": ".github/workflows/loop-planner.yml",
"templates/loop-builder.yml.tmpl": ".github/workflows/loop-builder.yml",
"templates/loop-triage.yml.tmpl": ".github/workflows/loop-triage.yml",
}
// static (non-templated) docs: template name -> destination.
var docs = map[string]string{
"templates/NORTH_STAR.md": ".github/loop/NORTH_STAR.md",
"templates/PRIORITIES.md": ".github/loop/PRIORITIES.md",
}
func init() {
cmd.Register(&cli.Command{
Name: "loop",
Usage: "Scaffold an autonomous improvement loop for a repository",
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage — gated by CI.
The loop has three roles:
planner keeps a ranked queue in .github/loop/PRIORITIES.md
builder builds the top open item as a single-concern PR (auto-merged on green CI)
triage turns CI failures into scoped fix issues back into the queue
Direction lives in .github/loop/NORTH_STAR.md — edit it to steer the loop.
Examples:
# Scaffold the loop into the current repo
micro loop init
# Customize the agent, token secret, base branch, and CI workflow name
micro loop init --agent @codex --token-secret LOOP_TOKEN \
--branch main --ci-workflow CI
# Check that a repo is wired correctly
micro loop verify`,
Subcommands: []*cli.Command{
{
Name: "init",
Usage: "Scaffold the loop workflows and queue into a repo",
Flags: []cli.Flag{
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "name: of the CI workflow triage watches for failures", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
},
Action: runInit,
},
{
Name: "verify",
Usage: "Verify a repo is wired for the loop",
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
Action: runVerify,
},
},
})
}
func runInit(c *cli.Context) error {
dir := c.String("dir")
cfg := config{
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: c.String("ci-workflow"),
PlannerCron: c.String("planner-cron"),
BuilderCron: c.String("builder-cron"),
}
if cfg.DefaultBranch == "" {
cfg.DefaultBranch = detectDefaultBranch(dir)
}
if !strings.HasPrefix(cfg.AgentMention, "@") {
cfg.AgentMention = "@" + cfg.AgentMention
}
if err := scaffold(dir, cfg, c.Bool("force")); err != nil {
return err
}
printNextSteps(cfg)
return nil
}
// scaffold renders the workflow templates and writes the loop files into dir.
// Static docs (NORTH_STAR, PRIORITIES) are never clobbered even with force, so
// re-running init can't wipe curated direction or a hand-tuned queue.
func scaffold(dir string, cfg config, force bool) error {
for tmplName, dest := range workflows {
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
}
for tmplName, dest := range docs {
full := filepath.Join(dir, dest)
if fileExists(full) {
fmt.Printf(" kept %s (already exists)\n", dest)
continue
}
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return err
}
if err := writeFile(full, b, true); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
}
return nil
}
// verifyState reports what's wrong with dir's loop setup: warnings are
// non-fatal, missing are required files that aren't present.
func verifyState(dir string) (warnings, missing []string) {
for _, dest := range workflows {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
for _, dest := range docs {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
// The loop is only as good as its gate: warn if there's no non-loop
// workflow to serve as CI.
if !hasCIWorkflow(dir) {
warnings = append(warnings, "no non-loop workflow found in .github/workflows — the loop needs a CI gate (build/test/lint) to merge safely")
}
return warnings, missing
}
func runVerify(c *cli.Context) error {
dir := c.String("dir")
warnings, missing := verifyState(dir)
for _, m := range missing {
fmt.Printf(" MISSING %s\n", m)
}
for _, w := range warnings {
fmt.Printf(" WARN %s\n", w)
}
if len(missing) > 0 {
return fmt.Errorf("loop is not fully scaffolded (%d file(s) missing) — run `micro loop init`", len(missing))
}
fmt.Println(" OK loop workflows and queue are present")
fmt.Println()
fmt.Println("Reminders the CLI can't check:")
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
if len(warnings) > 0 {
return fmt.Errorf("%d warning(s) — see above", len(warnings))
}
return nil
}
func render(tmplName string, cfg config) ([]byte, error) {
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return nil, err
}
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
// untouched — only << >> placeholders are substituted.
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
if err != nil {
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, cfg); err != nil {
return nil, fmt.Errorf("render %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func writeFile(path string, content []byte, force bool) error {
if fileExists(path) && !force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
// not one of the loop's own (i.e. a plausible CI gate).
func hasCIWorkflow(dir string) bool {
entries, err := os.ReadDir(filepath.Join(dir, ".github", "workflows"))
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "loop-") {
continue
}
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return true
}
}
return false
}
// detectDefaultBranch best-effort resolves the repo's default branch, falling
// back to "main".
func detectDefaultBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
if err == nil {
ref := strings.TrimSpace(string(out))
if i := strings.LastIndex(ref, "/"); i >= 0 {
ref = ref[i+1:]
}
if ref != "" {
return ref
}
}
return "main"
}
func printNextSteps(cfg config) {
fmt.Printf(`
Loop scaffolded. Next steps (the CLI can't do these for you):
1. Edit .github/loop/NORTH_STAR.md — the direction the loop aligns to.
Seed .github/loop/PRIORITIES.md with a few real items.
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
requests + issues write) for an account the agent (%s) responds to.
The workflows no-op until this secret exists.
3. Ensure a CI workflow named %q exists and that branch protection on %q
requires its checks with 0 approving reviews — that green-CI gate is
what lets the builder auto-merge safely.
4. Commit these files, then trigger a run:
Actions → "Loop: Planner" / "Loop: Builder" → Run workflow.
Verify anytime with: micro loop verify
`, cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
}
+107
View File
@@ -0,0 +1,107 @@
package loop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenderProducesValidPlaceholderFreeYAML(t *testing.T) {
cfg := config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
PlannerCron: "0 * * * *",
BuilderCron: "30 * * * *",
}
for tmplName := range workflows {
rendered, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", tmplName, err)
}
s := string(rendered)
// No unresolved substitution delimiters should remain...
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
t.Errorf("%s still contains << >> placeholders after render", tmplName)
}
// ...but GitHub Actions' own ${{ }} expressions must survive verbatim.
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", tmplName)
}
// The configured values must be substituted in.
if !strings.Contains(s, "@codex") {
t.Errorf("%s missing agent mention", tmplName)
}
// Structural sanity: a workflow needs these top-level keys.
for _, key := range []string{"name:", "on:", "jobs:"} {
if !strings.Contains(s, key) {
t.Errorf("%s missing top-level %q", tmplName, key)
}
}
}
}
func TestInitThenVerify(t *testing.T) {
dir := t.TempDir()
// A non-loop workflow so verify's CI-gate check passes.
mustWrite(t, filepath.Join(dir, ".github/workflows/ci.yml"), "name: CI\n")
if err := scaffold(dir, config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
PlannerCron: "0 * * * *",
BuilderCron: "30 * * * *",
}, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
for _, dest := range workflows {
if !fileExists(filepath.Join(dir, dest)) {
t.Errorf("expected %s to be written", dest)
}
}
for _, dest := range docs {
if !fileExists(filepath.Join(dir, dest)) {
t.Errorf("expected %s to be written", dest)
}
}
// A second scaffold without --force must fail on an existing workflow.
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err == nil {
t.Error("expected second scaffold without --force to fail")
}
}
func TestVerifyMissingFilesFails(t *testing.T) {
dir := t.TempDir()
if _, missing := verifyState(dir); len(missing) == 0 {
t.Error("expected missing files in an empty dir")
}
}
func TestVerifyWarnsWithoutCIGate(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Only loop-* workflows exist → no CI gate.
if hasCIWorkflow(dir) {
t.Error("expected no CI gate when only loop-* workflows are present")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
+23
View File
@@ -0,0 +1,23 @@
# North Star
> **Edit this file.** It is the single source of direction the loop aligns every
> increment to. The planner ranks work against it; the builder builds toward it.
> Be concrete — vague direction produces vague increments.
## Mission
<One or two sentences: the problem this repository solves and who it's for.>
## Right now
<The current priority — what "better" means this month. The planner weights the
queue toward this.>
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI, not a human review — keep the test/lint suite strong,
because the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
+16
View File
@@ -0,0 +1,16 @@
# Priorities
A single ranked queue, highest-value first. Each item links a scoped issue the
loop can build and CI can verify. The **planner** keeps this current; the
**builder** takes the top item whose issue is still open.
<!--
Seed this with a few real items to give the loop a running start, e.g.:
1. Add retry with backoff to the HTTP client — #123
2. Document the config file format — #124
3. Fix flaky timeout in the cache tests — #125
The planner will re-rank, drop completed items, and file issues for new gaps.
Reorder or edit this file at any time to redirect the loop.
-->
@@ -0,0 +1,46 @@
name: "Loop: Builder"
# Generated by `micro loop init`. The GENERATOR of the loop: each run it opens a
# fresh tracking issue and dispatches the agent to build the top open item from
# .github/loop/PRIORITIES.md as a single-concern PR, then enables native
# auto-merge so the PR lands once the required CI checks pass. Branch protection
# (required checks, 0 approvals) is the gate — there is no merge sweep.
#
# A FRESH issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch
# and only the first PR opens. Gated on << .TokenSecret >> (see loop-planner.yml).
on:
workflow_dispatch: {}
schedule:
- cron: "<< .BuilderCron >>"
permissions:
issues: write
concurrency:
group: loop-builder
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open an increment issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: build increment #$RUN_NUMBER" \
--body "Autonomous build increment. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching the builder."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Build one increment for this repository, aligned to .github/loop/NORTH_STAR.md. PICK THE WORK: take the highest-ranked item in .github/loop/PRIORITIES.md whose linked issue is still OPEN — that is your task and its issue is the one you close. If the queue is empty or every item's issue is closed, pick the single highest-value improvement yourself. Implement it, then VERIFY the project builds, tests, and lints (use the commands documented in the README or the CI workflow). Open the PR YOURSELF from the shell — do NOT use a make_pr tool (it may be a no-op stub): \`git switch -c loop/increment-$ISSUE_NUM\`, \`git push -u origin loop/increment-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this tracker>\"\`, then \`gh pr merge --squash --auto --delete-branch\` so it lands on green CI. One concern per PR; stay out of breaking public API and brand/positioning copy."
@@ -0,0 +1,48 @@
name: "Loop: Planner"
# Generated by `micro loop init`. Part of an autonomous improvement loop:
# a PLANNER (this file) keeps a ranked queue, a BUILDER builds the top item,
# and CI + a TRIAGE pass are the evaluator. The loop dispatches a coding agent
# by @mention on a fresh tracking issue each run.
#
# Gated on the << .TokenSecret >> secret: the agent ignores @mentions from the
# github-actions bot, so the dispatch must post as a real user (a PAT). Until
# that secret is set the workflow runs but no-ops. Direction lives in
# .github/loop/NORTH_STAR.md; the ranked queue in .github/loop/PRIORITIES.md.
on:
workflow_dispatch: {}
schedule:
- cron: "<< .PlannerCron >>"
permissions:
issues: write
concurrency:
group: loop-planner
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a planning issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping."
echo "The agent ignores @mentions from the github-actions bot, so a"
echo "user PAT is required. Add a << .TokenSecret >> secret to activate."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: planning review #$RUN_NUMBER" \
--body "Autonomous planning pass. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching the planner."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Act as the planner for this repository. (1) Read .github/loop/NORTH_STAR.md for direction, then assess current state — recently merged PRs and open issues — so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) Maintain a SINGLE ranked queue in .github/loop/PRIORITIES.md, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap with no issue, file one: \`gh issue create --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. (3) If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c loop/planner-$ISSUE_NUM\`, \`git push -u origin loop/planner-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate, just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking or architectural changes yourself — surface those as notes for a human. Open the PR yourself from the shell with gh; do not use a make_pr tool."
@@ -0,0 +1,46 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("<< .CIWorkflow >>") fails on a non-PR run, this dispatches the
# agent to root-cause the failure and file scoped fix issues back into the
# planner's queue — so failures become fixes with no human in the middle, short
# of a decision that is genuinely a human's. Gated on << .TokenSecret >>.
on:
workflow_run:
workflows: ["<< .CIWorkflow >>"]
types: [completed]
permissions:
issues: write
concurrency:
group: loop-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: File a triage issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID" \
--body "The '<< .CIWorkflow >>' workflow failed: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Triage the failed CI run at $RUN_URL. Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment 'recurred' there instead of filing a duplicate. For each genuine, self-contained defect, file a scoped issue (\`gh issue create --title \"<scoped fix>\" --body \"<root cause, where, acceptance>\"\`) so the planner/builder can pick it up and the next CI run verifies it. IGNORE transient flakes (network blips, provider outages, timeouts with no code cause). Anything needing a breaking or architectural change: label it needs-human and describe it — do NOT auto-file it as a routine fix. Close this issue (\`gh issue close $ISSUE_NUM\`) when triage is done."
+1
View File
@@ -13,6 +13,7 @@ import (
_ "go-micro.dev/v6/cmd/micro/cli/deploy"
_ "go-micro.dev/v6/cmd/micro/flow"
_ "go-micro.dev/v6/cmd/micro/inspect"
_ "go-micro.dev/v6/cmd/micro/loop"
_ "go-micro.dev/v6/cmd/micro/mcp"
_ "go-micro.dev/v6/cmd/micro/resource"
_ "go-micro.dev/v6/cmd/micro/run"
+246
View File
@@ -0,0 +1,246 @@
// Agent Ollama — a self-contained agent powered by Ollama Cloud.
//
// This example demonstrates the full harness loop — service tools, custom
// tools, agent memory, guardrails, and streaming — using the Ollama
// provider with gpt-oss:120b on Ollama Cloud.
//
// It creates a "knowledge" service with two endpoints (Add, Search) that
// the agent discovers as tools, plus a custom "current_time" tool. The
// agent answers natural-language questions by calling those tools.
//
// Run (Ollama Cloud — default):
//
// OLLAMA_API_KEY=your-key go run main.go
//
// Run (local Ollama):
//
// OLLAMA_BASE_URL=http://localhost:11434 \
// OLLAMA_MODEL=llama3.2 \
// go run main.go
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"go-micro.dev/v6"
"go-micro.dev/v6/agent"
)
// ---------------------------------------------------------------------------
// knowledge service — a tiny in-memory knowledge base
// ---------------------------------------------------------------------------
type KnowledgeEntry struct {
ID string `json:"id" description:"Unique entry identifier"`
Topic string `json:"topic" description:"Topic or category"`
Content string `json:"content" description:"The knowledge content"`
}
type AddKnowledgeRequest struct {
Topic string `json:"topic" description:"Topic or category (required)"`
Content string `json:"content" description:"The knowledge content (required)"`
}
type AddKnowledgeResponse struct {
Entry *KnowledgeEntry `json:"entry" description:"The added entry"`
}
type SearchKnowledgeRequest struct {
Topic string `json:"topic,omitempty" description:"Filter by topic (optional)"`
Keyword string `json:"keyword,omitempty" description:"Search keyword in content (optional)"`
}
type SearchKnowledgeResponse struct {
Entries []*KnowledgeEntry `json:"entries" description:"Matching entries"`
}
type KnowledgeService struct {
mu sync.RWMutex
entries []*KnowledgeEntry
nextID int
}
// Add stores a new knowledge entry.
//
// @example {"topic": "go", "content": "Go interfaces are implicit."}
func (s *KnowledgeService) Add(ctx context.Context, req *AddKnowledgeRequest, rsp *AddKnowledgeResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
e := &KnowledgeEntry{
ID: fmt.Sprintf("kb-%d", s.nextID),
Topic: req.Topic,
Content: req.Content,
}
s.entries = append(s.entries, e)
rsp.Entry = e
return nil
}
// Search finds knowledge entries by topic or keyword.
//
// @example {"topic": "go"}
// @example {"keyword": "interface"}
func (s *KnowledgeService) Search(ctx context.Context, req *SearchKnowledgeRequest, rsp *SearchKnowledgeResponse) error {
s.mu.RLock()
defer s.mu.RUnlock()
for _, e := range s.entries {
if req.Topic != "" && !strings.EqualFold(e.Topic, req.Topic) {
continue
}
if req.Keyword != "" && !strings.Contains(strings.ToLower(e.Content), strings.ToLower(req.Keyword)) {
continue
}
rsp.Entries = append(rsp.Entries, e)
}
return nil
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func main() {
// Ollama Cloud is the default. Override with env vars for local Ollama.
baseURL := os.Getenv("OLLAMA_BASE_URL")
if baseURL == "" {
baseURL = "https://ollama.com/v1"
}
model := os.Getenv("OLLAMA_MODEL")
if model == "" {
model = "gpt-oss:120b"
}
apiKey := os.Getenv("OLLAMA_API_KEY")
fmt.Println("╔══════════════════════════════════════════╗")
fmt.Println("║ Ollama-Powered Go Micro Agent ║")
fmt.Println("╚══════════════════════════════════════════╝")
fmt.Println()
fmt.Printf(" Ollama URL: %s\n", baseURL)
fmt.Printf(" Model: %s\n", model)
if apiKey != "" {
fmt.Printf(" API Key: (set)\n")
} else {
fmt.Printf(" API Key: (none — set OLLAMA_API_KEY)\n")
}
fmt.Println()
// 1. Start the knowledge service. Its handlers become agent tools.
svc := micro.NewService("knowledge")
svc.Handle(new(KnowledgeService))
go svc.Run()
// Give the service a moment to register.
time.Sleep(2 * time.Second)
// 2. Create the agent. It discovers the knowledge service endpoints
// as tools automatically, plus gets a custom "current_time" tool.
ag := micro.NewAgent("ollama-assistant",
micro.AgentServices("knowledge"),
micro.AgentPrompt(
"You are a helpful knowledge assistant. You can search and add to "+
"a knowledge base using the knowledge service tools. "+
"When asked about the current time, use the current_time tool. "+
"Be concise and factual.",
),
micro.AgentProvider("ollama"),
micro.AgentModel(model),
micro.AgentAPIKey(apiKey),
micro.AgentBaseURL(baseURL),
micro.AgentMaxSteps(10),
micro.AgentLoopLimit(3),
// Custom tool — any function, not tied to a service.
agent.WithTool(
"current_time",
"Get the current date and time in a human-readable format",
map[string]any{
"timezone": map[string]any{
"type": "string",
"description": "Optional timezone (defaults to local)",
},
},
func(ctx context.Context, input map[string]any) (string, error) {
tz, _ := input["timezone"].(string)
if tz == "" {
return time.Now().Format("2006-01-02 15:04:05 MST"), nil
}
loc, err := time.LoadLocation(tz)
if err != nil {
return "", fmt.Errorf("unknown timezone: %s", tz)
}
return time.Now().In(loc).Format("2006-01-02 15:04:05 MST"), nil
},
),
)
// 3. Seed initial knowledge via the agent's first question.
questions := []string{
"What time is it now?",
"Add a new knowledge entry: topic 'go', content 'Go interfaces are implicit — a type implements an interface by having the required methods.'",
"Add another entry: topic 'go', content 'Go is a statically typed, compiled language designed at Google.'",
"Add another entry: topic 'ai', content 'Large language models generate text by predicting the next token in a sequence.'",
"Search the knowledge base for entries about Go.",
"Search for everything in the knowledge base.",
}
fmt.Println("─── Agent Demo ───")
fmt.Println()
for i, q := range questions {
fmt.Printf("Q%d: %s\n", i+1, q)
fmt.Print("A: ")
resp, err := ag.Ask(context.Background(), q)
if err != nil {
fmt.Printf("error: %v\n", err)
fmt.Println()
continue
}
// Show tool calls the agent made.
if len(resp.ToolCalls) > 0 {
for _, tc := range resp.ToolCalls {
args, _ := json.Marshal(tc.Input)
fmt.Printf(" [tool] %s(%s)\n", tc.Name, string(args))
}
}
fmt.Println(resp.Reply)
if resp.Reply == "" && len(resp.ToolCalls) == 0 {
fmt.Println("(no response)")
}
fmt.Println()
}
// 4. Streaming demonstration.
fmt.Println("─── Streaming Demo ───")
fmt.Println()
streamQ := "Explain what Go Micro is in two sentences."
fmt.Printf("Q: %s\n", streamQ)
fmt.Print("A: ")
stream, err := ag.Stream(context.Background(), streamQ)
if err != nil {
fmt.Printf("stream error: %v\n", err)
} else {
for {
chunk, err := stream.Recv()
if err != nil {
break
}
if chunk.Reply != "" {
fmt.Print(chunk.Reply)
}
}
fmt.Println()
}
fmt.Println()
fmt.Println("Done.")
}
+5 -5
View File
@@ -21,11 +21,11 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Add first-agent preflight diagnostics** ([#3604](https://github.com/micro/go-micro/issues/3604)) — the 0→hero docs handoff shipped, so the next adoption bottleneck is install/runtime confidence before a new user tries the first provider-backed agent. Keep this first because the current strategic goal is developer adoption: one no-secret diagnostic should tell a developer whether Go, the `micro` binary/runtime, provider-key setup, and local ports are ready before the first-agent walkthrough.
2. **Finalize the universe notify step after an agent-backed timeout** ([#3589](https://github.com/micro/go-micro/issues/3589)) — recent live-provider CI exposed a real services → agents → workflows seam: an agent-backed notify side effect can complete while the client observes a timeout, leaving pending flow state and late duplicate notifications. Keep this high because it protects the 0→hero/reliability contract, but leave the adoption preflight first so the queue does not drift back to only internal hardening.
3. **Prevent duplicate tool side effects in the plan/delegate harness** ([#3559](https://github.com/micro/go-micro/issues/3559)) — correctness still matters where it protects real user trust. Plan/delegate is central to the services → agents lifecycle, and duplicate side effects undermine the “agent as dependable service” story.
4. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — keep interop conformance trustworthy without letting it dominate the adoption queue. This is scoped, testable, and protects the A2A promise developers see in the README and site.
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — after the on-ramp items, the highest-value remaining Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
1. **Make the first-agent on-ramp discoverable from README and website navigation** ([#3640](https://github.com/micro/go-micro/issues/3640)) — the install and first-run smoke contract shipped in #3635, but adoption still depends on a new developer finding the next step without already knowing the guide names. Put the walkable path from install/scaffold to first agent, chat/inspect, and 0→hero in the README/site wayfinding so the verified harness becomes lived DX rather than hidden depth.
2. **Make plan/delegate live-provider conformance avoid duplicate task side effects** ([#3626](https://github.com/micro/go-micro/issues/3626)) — atlascloud still has an open Now-phase trust gap where plan/delegate created duplicate launch tasks and missed the delegated notification. This remains the highest open runtime fix because plan/delegate is the bridge from agent reasoning to service side effects; developers cannot trust the services → agents lifecycle if a model can replay tool calls and leave handoff pending.
3. **Make universe checkout conformance send exactly one concierge notification** ([#3633](https://github.com/micro/go-micro/issues/3633)) — the newest live-provider scan found the durable checkout/universe path resuming correctly but notifying the buyer twice. Rank it next because durable workflows are the 0→hero proof, and resume idempotency must be boring before deeper observability or future interop work matters.
4. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path still receives the tool surface, without letting protocol depth outrank the on-ramp or side-effect safety.
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap after the live-provider side-effect fixes is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
6. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
7. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Smoke-test the documented install.sh path without network access.
# It builds the local CLI, packages it like a release archive, installs it into a
# temporary bin directory through internal/scripts/install.sh, then checks the
# first-run command boundaries shown in the getting-started docs.
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
ARCHIVE_DIR="$TMP_DIR/archive"
INSTALL_DIR="$TMP_DIR/install"
ARCHIVE="$TMP_DIR/micro-local.tar.gz"
mkdir -p "$ARCHIVE_DIR" "$INSTALL_DIR"
CGO_ENABLED=0 go build -o "$ARCHIVE_DIR/micro" ./cmd/micro
chmod +x "$ARCHIVE_DIR/micro"
tar -C "$ARCHIVE_DIR" -czf "$ARCHIVE" micro
MICRO_INSTALL_DIR="$INSTALL_DIR" \
MICRO_INSTALL_ARCHIVE="$ARCHIVE" \
MICRO_VERSION="local-smoke" \
PATH="$INSTALL_DIR:$PATH" \
"$ROOT/internal/scripts/install.sh" > "$TMP_DIR/install.out"
MICRO="$INSTALL_DIR/micro"
if [[ ! -x "$MICRO" ]]; then
echo "installed micro binary not found at $MICRO" >&2
cat "$TMP_DIR/install.out" >&2
exit 1
fi
require_output() {
local description=$1
local expected=$2
shift 2
local output
if ! output=$("$MICRO" "$@" 2>&1); then
echo "micro $* failed while checking $description" >&2
echo "$output" >&2
exit 1
fi
if [[ "$output" != *"$expected"* ]]; then
echo "micro $* missing expected text '$expected' for $description" >&2
echo "$output" >&2
exit 1
fi
}
require_output "version" "micro version" --version
require_output "root help" "COMMANDS" --help
require_output "service scaffold" "micro new" new --help
require_output "first-agent preflight" "preflight" agent preflight --help
require_output "local runtime" "micro run" run --help
require_output "agent chat" "micro chat" chat --help
require_output "agent inspection" "micro inspect agent" inspect agent --help
require_output "flow inspection" "micro inspect flow" inspect flow --help
echo "✓ install smoke path verified"
+4
View File
@@ -243,6 +243,8 @@ func runPlanDelegate(provider string) error {
cl := harnessutil.Client(provider, reg)
mem := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
commsCheckpoint := flow.StoreCheckpoint(mem, "agent-comms")
conductorCheckpoint := flow.StoreCheckpoint(mem, "agent-conductor")
// Real services.
taskSvc := new(TaskService)
@@ -267,6 +269,7 @@ func runPlanDelegate(provider string) error {
agent.Prompt("You handle outbound notifications. Use the notify service."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(commsCheckpoint),
}
commsOpts = append(commsOpts, liveAgentOpts...)
comms := agent.New(commsOpts...)
@@ -281,6 +284,7 @@ func runPlanDelegate(provider string) error {
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(conductorCheckpoint),
}
conductorOpts = append(conductorOpts, liveAgentOpts...)
conductor := agent.New(conductorOpts...)
+50 -3
View File
@@ -32,6 +32,7 @@ import (
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"time"
@@ -107,17 +108,63 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type Notify struct{ sent int64 }
type Notify struct {
mu sync.Mutex
sent int64
seen map[string]struct{}
}
// Send delivers a notification.
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
key := req.To + "\x00" + req.Message
s.mu.Lock()
if s.seen == nil {
s.seen = make(map[string]struct{})
}
if _, ok := s.seen[key]; ok {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 duplicate suppressed to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
s.seen[key] = struct{}{}
s.mu.Unlock()
atomic.AddInt64(&s.sent, 1)
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
dispatch := flow.Dispatch(agentName)
return func(ctx context.Context, in flow.State) (flow.State, error) {
before := atomic.LoadInt64(&ntf.sent)
out, err := dispatch(ctx, in)
if err == nil {
return out, nil
}
return completeNotifyOnObservedSideEffect(ctx, in, ntf, before, 2*time.Second, err)
}
}
func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf *Notify, before int64, wait time.Duration, dispatchErr error) (flow.State, error) {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
if atomic.LoadInt64(&ntf.sent) > before {
in.Data = []byte("Buyer notified.")
return in, nil
}
select {
case <-ctx.Done():
return in, dispatchErr
case <-time.After(25 * time.Millisecond):
}
}
return in, dispatchErr
}
// ---------------------------------------------------------------------------
// mock LLM — the only fake. The concierge agent uses it to decide to notify.
// ---------------------------------------------------------------------------
@@ -275,7 +322,7 @@ func runUniverse(provider string) int {
flow.Step{Name: "reserve", Run: flow.Call("inventory", "Inventory.Reserve")},
flow.Step{Name: "charge", Run: flow.Call("payment", "Payment.Charge")},
flow.Step{Name: "confirm", Run: flow.Call("orders", "Orders.Confirm")},
flow.Step{Name: "notify", Run: flow.Dispatch("concierge")},
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
),
)
if err := checkout.Register(reg, br, cl); err != nil {
@@ -331,7 +378,7 @@ func runUniverse(provider string) int {
check(atomic.LoadInt64(&inv.reserves) == 1, "inventory still reserved exactly once (completed step not replayed)")
check(atomic.LoadInt64(&pay.attempts) == 2, "payment attempted twice (failed once, then charged)")
check(atomic.LoadInt64(&ord.confirms) == 1, "order confirmed after resume")
check(atomic.LoadInt64(&ntf.sent) >= 1, "buyer notified by the concierge agent")
check(atomic.LoadInt64(&ntf.sent) == 1, "buyer notified exactly once by the concierge agent")
check(atomic.LoadInt64(&wrapped) >= 1, "agent tool-execution wrapper observed the call")
if pend, _ := checkout.Pending(ctx); true {
+50
View File
@@ -1,7 +1,13 @@
package main
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"go-micro.dev/v6/flow"
)
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
@@ -18,3 +24,47 @@ func TestUniverseHarnessContract(t *testing.T) {
t.Fatalf("universe harness exited with code %d", code)
}
}
func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
ntf := new(Notify)
before := atomic.LoadInt64(&ntf.sent)
go func() {
time.Sleep(30 * time.Millisecond)
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "buyer@acme.com",
Message: "Your order is confirmed.",
}, &rsp); err != nil {
t.Errorf("send notification: %v", err)
}
}()
out, err := completeNotifyOnObservedSideEffect(
context.Background(),
flow.State{Data: []byte(`{"order":"order-1"}`)},
ntf,
before,
time.Second,
errors.New("client observed timeout"),
)
if err != nil {
t.Fatalf("notify completion returned error: %v", err)
}
if got := out.String(); got != "Buyer notified." {
t.Fatalf("result = %q, want Buyer notified.", got)
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent = %d, want 1", got)
}
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "buyer@acme.com",
Message: "Your order is confirmed.",
}, &rsp); err != nil {
t.Fatalf("duplicate send: %v", err)
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent after duplicate = %d, want 1", got)
}
}
+14 -9
View File
@@ -4,14 +4,17 @@ This directory owns the no-secret reference scenario for the Go Micro
services → agents → workflows lifecycle. It is intentionally small and
scripted so CI can run it on every push without external services or model keys.
`run.sh` verifies four boundaries together:
`run.sh` verifies five boundaries together:
1. **Run**`micro run` remains available as the local development entry point.
2. **Chat**`micro chat` remains available as the interactive agent entry point.
3. **Inspect**`micro inspect agent <name>` and `micro inspect flow <name>`
1. **First agent**`micro new`, `micro agent preflight`, `micro run`,
`micro chat`, and `micro inspect agent <name>` remain available as the
documented first-agent walkthrough path.
2. **Run**`micro run` remains available as the local development entry point.
3. **Chat**`micro chat` remains available as the interactive agent entry point.
4. **Inspect**`micro inspect agent <name>` and `micro inspect flow <name>`
remain available as the local run-history inspection step, with `micro flow
runs` preserving durable workflow history inspection.
4. **Deploy**`micro deploy --dry-run <target>` remains available as the
5. **Deploy**`micro deploy --dry-run <target>` remains available as the
deployment-boundary checkpoint. The dry run resolves configured deploy targets
and services and prints the remote build/copy/systemd/health plan without
building binaries, opening SSH connections, running `rsync`, or touching
@@ -24,15 +27,17 @@ and A2A with only the LLM mocked.
## Local and CI entry points
The default GitHub harness workflow runs this script on every push and pull
request after the 0→1 scaffold contract. Developers can run the same no-secret
contract locally with:
request after the install smoke check and 0→1 scaffold contract. Developers can
verify the installer seam alone with `make install-smoke`, or run the same
no-secret contract locally with:
```sh
make harness
```
That target intentionally exercises both 0→1 scaffold variants, the 0→hero
scenario, the event-driven agent-flow harness, and mock provider conformance, so
That target intentionally exercises the install script smoke path, both 0→1
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
mock provider conformance, so
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
outside CI as well. Live provider checks remain separate and gated by configured
API keys (`make provider-conformance` or the scheduled/manual CI job).
@@ -14,8 +14,10 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
for _, want := range []string{
"make harness",
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
"./internal/harness/zero-to-hero-ci/run.sh",
"go run ./internal/harness/agent-flow",
"make provider-conformance-mock",
+5 -2
View File
@@ -6,9 +6,12 @@ cd "$ROOT"
# Keep the developer inner-loop boundaries executable and discoverable in CI
# without secrets or long-running daemons.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider.
# runtime and mock only the LLM provider. The support example is the maintained
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
# journey cannot drift from the framework.
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
+11 -4
View File
@@ -23,8 +23,12 @@ case $OS in
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
# tests that verify the installer without writing to a real system directory.
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$MICRO_INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
@@ -44,8 +48,11 @@ fi
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download
if command -v curl &> /dev/null; then
# Download, or use a local release archive supplied by the deterministic smoke
# harness. The default path still fetches the documented GitHub release artifact.
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
elif command -v curl &> /dev/null; then
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
+5 -4
View File
@@ -5,6 +5,10 @@ core:
url: /docs/getting-started.html
- title: AI Integration
url: /docs/ai-integration.html
- title: Your First Agent
url: /docs/guides/your-first-agent.html
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: MCP & AI Agents
url: /docs/mcp.html
- title: Deployment
@@ -32,12 +36,8 @@ examples:
- title: Real-World Examples
url: /docs/examples/realworld/
guides:
- title: Your First Agent
url: /docs/guides/your-first-agent.html
- title: Debugging your agent
url: /docs/guides/debugging-agents.html
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: Plan & Delegate
url: /docs/guides/plan-delegate.html
- title: Agent Guardrails
@@ -78,6 +78,7 @@ project:
- title: Server (optional)
url: /docs/server.html
search_order:
- /docs/guides/your-first-agent.html
- /docs/guides/zero-to-hero.html
- /docs/guides/debugging-agents.html
- /docs/getting-started.html
@@ -43,6 +43,7 @@ The built-in providers currently register these capability interfaces:
| `gemini` | Yes | No | No | No |
| `groq` | Yes | No | No | Yes |
| `mistral` | Yes | No | No | Yes |
| `ollama` | Yes | No | No | Yes |
| `openai` | Yes | Yes | No | Yes |
| `together` | Yes | No | No | Yes |
@@ -14,6 +14,7 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
@@ -47,7 +47,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
Plain service calls work without a model key; the key is only needed when the
agent reasons over tools.
Run the read-only first-agent preflight before starting the walkthrough:
Run the read-only first-agent preflight before starting the walkthrough. The same CLI boundary is covered by CI with `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1`, so the documented scaffold → run → chat → inspect path stays visible in the local harness:
```sh
micro agent preflight
+12 -1
View File
@@ -18,11 +18,13 @@ cloud credentials?"
| Boundary | Contract | CI check |
| --- | --- | --- |
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Inspect | `micro inspect agent`, `micro inspect flow`, and `micro flow runs` remain discoverable for run history. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
| Runtime | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
## Run the runnable example
@@ -52,13 +54,22 @@ SSH access, or remote service is required.
Use the smaller checks when you are working on one seam:
```sh
# Install script and first-run CLI boundary, with no network or provider keys.
make install-smoke
# Scaffold → run/call contract.
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
# First-agent walkthrough boundary: scaffold, preflight, run, chat, inspect.
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
# Maintained 0→hero support-desk reference app.
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
# Durable services → agents → workflows reference scenarios.
./internal/harness/zero-to-hero-ci/run.sh
+1 -1
View File
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
## Learn More
Start with [Getting Started](getting-started.html) for install and the first local service. If you want the full services → agents → workflows on-ramp in one walkable sequence, use the [0→hero reference path](guides/zero-to-hero.html): it links the exact scaffold, run, chat, inspect, and deploy dry-run commands covered by CI.
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold run chat inspect deploy dry-run lifecycle covered by CI.
Otherwise continue to read the docs for more information about the framework.
+11 -4
View File
@@ -23,8 +23,12 @@ case $OS in
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
# tests that verify the installer without writing to a real system directory.
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$MICRO_INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
@@ -44,8 +48,11 @@ fi
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download
if command -v curl &> /dev/null; then
# Download, or use a local release archive supplied by the deterministic smoke
# harness. The default path still fetches the documented GitHub release artifact.
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
elif command -v curl &> /dev/null; then
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
+4
View File
@@ -100,6 +100,10 @@ func AgentModel(m string) AgentOption { return agent.Model(m) }
// AgentAPIKey sets the API key for the LLM provider.
func AgentAPIKey(k string) AgentOption { return agent.APIKey(k) }
// AgentBaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func AgentBaseURL(url string) AgentOption { return agent.BaseURL(url) }
// ApproveFunc gates an agent's tool calls before they run.
type ApproveFunc = agent.ApproveFunc