Compare commits

..

28 Commits

Author SHA1 Message Date
Codex a2a09635f6 docs: refresh planner priorities for 3982
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-04 22:33:23 +00:00
Asim Aslam 3f17f7b106 Stabilize first-agent broker isolation (#3981)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 23:01:12 +01:00
Asim Aslam 4ed4fca47d docs: align planner queue after 3975 (#3979)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:31:56 +01:00
Asim Aslam c2b11a1d31 docs: refresh planner queue for 3974 (#3978)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:30:50 +01:00
Asim Aslam f7c2ff0a74 docs: surface first-agent example path (#3975)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:22:42 +01:00
Asim Aslam ddf26acc47 docs: refresh planner priorities for 3968 (#3970)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 21:30:07 +01:00
Asim Aslam 54a80bc930 harness: verify first-agent on-ramp (#3967)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 21:05:30 +01:00
Asim Aslam 76f309f253 agent: alias text tool create calls to add (#3964)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:59:32 +01:00
Asim Aslam 9a3e6b7e5e docs(priorities): refresh planner queue for 3960 (#3961)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:28:25 +01:00
Asim Aslam eb76367b0b Stabilize agent conformance marker retry (#3959)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:02:09 +01:00
Asim Aslam 58b8eb3e6b docs(priorities): refresh planner queue for 3954 (#3956)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 18:29:59 +01:00
Asim Aslam f12788bd80 Preserve completed agent plan steps (#3953)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:59:30 +01:00
Asim Aslam 47997134af docs(priorities): refresh planner queue for 3949 (#3950)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:31:21 +01:00
Asim Aslam f35274af00 agent: harden AtlasCloud delegate conformance prompt (#3948)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:01:03 +01:00
Asim Aslam fefe3e5b4a docs(priorities): refresh planner queue for 3943 (#3944)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 16:36:35 +01:00
Asim Aslam b01fd477b5 agent: parse tagged text tool calls (#3942)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 16:13:07 +01:00
Asim Aslam dbca408982 docs(priorities): refresh planner queue for 3938 (#3939)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 15:42:04 +01:00
Asim Aslam fa9806c5a6 agent: retry missing delegate conformance (#3937)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 15:20:36 +01:00
Asim Aslam 95e7c387b2 docs(priorities): refresh planner queue for 3932 (#3933)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 14:36:37 +01:00
Asim Aslam cc54ae988d agent: retry conformance when tool is skipped (#3931)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 14:01:46 +01:00
Asim Aslam 6b86bbb27e docs(priorities): refresh planner queue for 3927 (#3928)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 13:34:42 +01:00
Asim Aslam 45624a25e0 Stabilize plan-delegate notify wait (#3926)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 13:06:43 +01:00
Asim Aslam e71e0a79fb docs(priorities): refresh planner queue for 3921 (#3922)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 12:41:51 +01:00
Asim Aslam ebc315505b examples: add smallest first agent (#3920)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 12:13:08 +01:00
Asim Aslam 100c7e11b9 docs(priorities): refresh planner queue for 3913 (#3915)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 11:41:35 +01:00
Asim Aslam 96ecf67573 agent: surface checkpoint resume hints in inspect (#3912)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 11:26:58 +01:00
Asim Aslam 71f4f6ac05 docs(priorities): refresh planner queue for 3907 (#3909)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 10:51:37 +01:00
Asim Aslam 0dd5ebe789 test(agent): broaden provider conformance scenario (#3906)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 10:32:49 +01:00
24 changed files with 854 additions and 39 deletions
+4 -4
View File
@@ -21,9 +21,9 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Add cross-provider agent conformance** ([#3901](https://github.com/micro/go-micro/issues/3901)) — #3895 closed the provider deadline/retry guidance gap and #3899 closed the AP2 foundation, leaving no open `codex` PRs or issues in flight. The highest-value Now-phase gap is therefore battle-testing the same first-agent behavior across providers: tool calling, multi-step runs, plan/delegate, and guardrails should fail loudly by provider instead of drifting behind a single happy-path backend. Keep default CI no-secret friendly with mock coverage and key-gated live providers.
2. **Checkpoint and resume durable agent runs** ([#3902](https://github.com/micro/go-micro/issues/3902)) — adoption now has install, scaffold, run, chat, inspect/history, and 0→hero coverage, but the lived harness story still has an asymmetry: flows checkpoint and resume while long agent runs do not. This is the top Next-phase agentic-depth gap because it makes services → agents → workflows feel like one runtime under interruption and deploy/restart conditions. Keep it additive; do not break the public agent API.
3. **Broaden provider streaming and keep chat/A2A streaming end to end** ([#3903](https://github.com/micro/go-micro/issues/3903)) — after conformance and durability, streaming is the next developer-visible seam in the inner loop. Real chat and long-running A2A tasks need token streaming to stay coherent from provider → `ai.Stream``micro chat` → A2A `message/stream`, with mock/default CI coverage plus key-gated live provider checks and safe fallback for non-streaming providers.
1. **Require delegated notify before plan-delegate completion** ([#3972](https://github.com/micro/go-micro/issues/3972)) — #3981 closed the first-agent broker isolation gap, leaving this as the latest live AtlasCloud plan/delegate harness regression. A tasks-complete-but-notify-missing run undermines evaluator trust at the agents → workflows seam, so it stays at the top until the harness can prove delegated work only completes after the required notification.
2. **Surface the first-agent and 0→hero example paths in the CLI** ([#3983](https://github.com/micro/go-micro/issues/3983)) — the README, website docs, and examples now describe a strong on-ramp, but adoption still depends on users finding those paths after install. Current goal is developer adoption, so the queue should keep a CI-verifiable CLI wayfinding task near the top instead of drifting entirely into internal conformance and observability work.
3. **Broaden provider streaming and keep chat/A2A streaming end to end** ([#3903](https://github.com/micro/go-micro/issues/3903)) — streaming remains the highest developer-visible Next-phase seam after the current conformance and wayfinding gaps. Real chat and long-running A2A tasks need token streaming to stay coherent from provider → `ai.Stream``micro chat` → A2A `message/stream`, with mock/default CI coverage plus key-gated live provider checks and safe fallback for non-streaming providers.
4. **Trace agent runs as OpenTelemetry spans** ([#3908](https://github.com/micro/go-micro/issues/3908)) — the blog/README/roadmap story promises an operable harness, and the developer on-ramp now includes chat, inspect, and run-history checkpoints. The next observability gap is production-grade trace correlation for `RunInfo`: steps, tool calls, delegation, status, durations, and failures should be visible as spans while defaulting to no-op when tracing is not configured.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+8 -4
View File
@@ -87,14 +87,15 @@ make harness
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
1. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
2. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
2. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
3. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
3. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
4. [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.
4. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
5. [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.
@@ -439,9 +440,12 @@ resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
## Examples
New to agents? Follow the [first-agent on-ramp](#first-agent-on-ramp), then use the [examples index](examples/README.md) for the full services → agents → workflows map.
- [hello-world](examples/hello-world/) — Basic RPC service
- [multi-service](examples/multi-service/) — Multiple services in one binary
- [mcp](examples/mcp/) — MCP integration with AI agents
- [first-agent](examples/first-agent/) — Smallest provider-free service-backed agent
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
- [agent-durable](examples/agent-durable/) — Checkpoint and resume an agent run without replaying completed tool side effects
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
+6 -2
View File
@@ -460,7 +460,7 @@ func (a *agentImpl) Run() error {
a.setup()
}
a.server = server.NewServer(
serverOpts := []server.Option{
server.Name(a.opts.Name),
server.Address(a.opts.Address),
server.Registry(a.opts.Registry),
@@ -468,7 +468,11 @@ func (a *agentImpl) Run() error {
"type": "agent",
"services": strings.Join(a.opts.Services, ","),
}),
)
}
if a.opts.Broker != nil {
serverOpts = append(serverOpts, server.Broker(a.opts.Broker))
}
a.server = server.NewServer(serverOpts...)
_ = pb.RegisterAgentHandler(a.server, a)
+64 -2
View File
@@ -364,12 +364,74 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
// handlePlan persists the supplied plan to the agent's memory and
// echoes it back so the model can see the stored state.
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
data, err := json.Marshal(call.Input)
input := preserveCompletedPlanSteps(a.loadPlan(), call.Input)
data, err := json.Marshal(input)
if err != nil {
return errResult(call.ID, "invalid plan: "+err.Error())
}
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
return ai.ToolResult{ID: call.ID, Value: input, Content: string(data)}
}
func preserveCompletedPlanSteps(stored string, input map[string]any) map[string]any {
if stored == "" {
return input
}
var previous map[string]any
if err := json.Unmarshal([]byte(stored), &previous); err != nil {
return input
}
completed := completedPlanTasks(previous)
if len(completed) == 0 {
return input
}
steps, ok := input["steps"].([]any)
if !ok {
return input
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
task, _ := step["task"].(string)
if completed[normalizePlanTask(task)] && isUnfinishedPlanStatus(step["status"]) {
step["status"] = "done"
}
}
return input
}
func completedPlanTasks(plan map[string]any) map[string]bool {
steps, ok := plan["steps"].([]any)
if !ok {
return nil
}
completed := map[string]bool{}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status != "done" {
continue
}
task, _ := step["task"].(string)
if task = normalizePlanTask(task); task != "" {
completed[task] = true
}
}
return completed
}
func normalizePlanTask(task string) string {
return strings.Join(strings.Fields(strings.ToLower(task)), " ")
}
func isUnfinishedPlanStatus(status any) bool {
s, _ := status.(string)
return s == "" || s == "pending" || s == "in_progress"
}
func (a *agentImpl) completeNextPlanStep() {
+26
View File
@@ -52,6 +52,32 @@ func TestHandlePlanPersists(t *testing.T) {
}
}
func TestHandlePlanPreservesCompletedSteps(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "done"},
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "done"},
},
}})
res := a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "done"},
map[string]any{"task": " delegate readiness notification TO comms agent ", "status": "in_progress"},
map[string]any{"task": "write summary", "status": "pending"},
},
}})
if res.Content == "" {
t.Fatal("handlePlan returned empty content")
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 1 || unfinished[0] != "write summary" {
t.Fatalf("unfinished plan steps = %v, want only write summary", unfinished)
}
}
func TestPlanShowsInPrompt(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
+246 -2
View File
@@ -118,7 +118,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
Name("conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt("You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input {\"value\":\"agent-conformance\"}, then attempt to delegate a summary to blocked-reviewer. If the delegate is refused, explain the refusal and answer with the echo result."),
Prompt(conformanceSystemPrompt(provider.name)),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(8)),
@@ -155,7 +155,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
}
a := New(agentOpts...)
resp, err := a.Ask(context.Background(), "Run the provider conformance check.")
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
@@ -179,6 +179,79 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
}
}
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
const maxAttempts = 3
prompt := initialPrompt
var resp *Response
for attempt := 1; attempt <= maxAttempts; attempt++ {
var err error
resp, err = a.Ask(ctx, prompt)
if err != nil {
return nil, err
}
sawRequiredTool := sawTool == nil || *sawTool
sawRequiredDelegate := sawBlockedDelegate == nil || *sawBlockedDelegate
hasMarker := responseHasConformanceMarker(resp)
if sawRequiredTool && sawRequiredDelegate && hasMarker {
return resp, nil
}
if attempt == maxAttempts {
break
}
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker)
}
return resp, nil
}
func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
return askWithConformanceRetry(ctx, a, initialPrompt, sawTool, nil)
}
func conformanceSystemPrompt(provider string) string {
prompt := "You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input {\"value\":\"agent-conformance\"}, then attempt to delegate a summary to blocked-reviewer with input {\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}. If the delegate is refused, explain the refusal and answer with the echo result."
if provider == "atlascloud" {
prompt += " AtlasCloud/minimax conformance note: the delegate attempt is mandatory after conformance_echo. If native tool_calls are unavailable, emit the delegate as <tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call> rather than answering in prose."
}
return prompt
}
func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(t *testing.T) {
prompt := conformanceSystemPrompt("atlascloud")
for _, want := range []string{
"delegate attempt is mandatory",
"<tool_call name=\"delegate\">",
`{"task":"summarize the conformance marker","to":"blocked-reviewer"}`,
} {
if !strings.Contains(prompt, want) {
t.Fatalf("atlascloud conformance prompt %q missing %q", prompt, want)
}
}
if strings.Contains(conformanceSystemPrompt("openai"), "AtlasCloud/minimax") {
t.Fatal("non-AtlasCloud prompt should not include provider-specific fallback guidance")
}
}
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool) string {
switch {
case !sawTool:
return "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: you must call conformance_echo exactly once with input {\"value\":\"agent-conformance\"} before any final answer, then include the tool result marker in the final answer."
case !sawBlockedDelegate:
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input {\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly <tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>. The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !hasMarker:
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
default:
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
}
}
func responseHasConformanceMarker(resp *Response) bool {
if resp == nil {
return false
}
return strings.Contains(resp.Reply, "agent-conformance-ok") || strings.Contains(resp.Reply, "agent-conformance")
}
func validateConformanceRequest(req *ai.Request, opts ai.Options) error {
if req.Prompt == "" {
return errors.New("missing prompt")
@@ -214,6 +287,177 @@ func TestAgentProviderConformanceFakeError(t *testing.T) {
}
}
func TestAgentProviderConformanceRetriesMissingTool(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
if attempts == 1 {
return &ai.Response{Reply: "I can confirm agent-conformance in prose only."}, nil
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
return &ai.Response{
Reply: "called conformance_echo",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-retry"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceToolRetry(context.Background(), a, "Run the provider conformance check.", &sawTool)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing tool", attempts)
}
if !sawTool {
t.Fatal("retry did not execute conformance_echo")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
}
func TestAgentProviderConformanceRetriesMissingMarker(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
if attempts == 1 {
return &ai.Response{Reply: "called conformance_echo and handled guarded delegate refusal without the required marker"}, nil
}
return &ai.Response{Reply: "agent-conformance-ok after guarded delegate refusal"}, nil
}
defer func() { fakeGen = nil }()
sawTool := true
sawBlockedDelegate := true
a := New(
Name("conformance-retry-marker"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing marker", attempts)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
}
}
func TestAgentProviderConformanceRetriesMissingDelegate(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
if attempts == 1 {
return &ai.Response{
Reply: "called conformance_echo but skipped delegate",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
delegate := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-delegate-1",
Name: "delegate",
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
})
return &ai.Response{
Reply: "called conformance_echo and handled guarded delegate refusal",
Answer: echo.Content + " " + delegate.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
var sawBlockedDelegate bool
a := New(
Name("conformance-retry-delegate"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
if tool == "delegate" {
sawBlockedDelegate = true
return false, "cross-provider conformance blocks delegate side effects"
}
return true, ""
}),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing delegate", attempts)
}
if !sawBlockedDelegate {
t.Fatal("retry did not attempt guarded delegate")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
}
}
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
+9
View File
@@ -5,6 +5,7 @@ import (
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
@@ -44,6 +45,7 @@ type Options struct {
Address string
Registry registry.Registry
Client client.Client
Broker broker.Broker
Store store.Store
HistoryLimit int
@@ -192,6 +194,13 @@ func WithClient(c client.Client) Option {
return func(o *Options) { o.Client = c }
}
// WithBroker sets the broker used by the agent service endpoint. Use an
// in-memory broker in local harnesses/tests to avoid sharing the package-wide
// default broker listener across concurrently running examples.
func WithBroker(b broker.Broker) Option {
return func(o *Options) { o.Broker = b }
}
// WithStore sets the store for agent memory.
func WithStore(s store.Store) Option {
return func(o *Options) { o.Store = s }
+6
View File
@@ -103,6 +103,8 @@ type RunSummary struct {
DurationMS int64 `json:"duration_ms,omitempty"`
Events int `json:"events"`
Status string `json:"status,omitempty"`
Checkpoint string `json:"checkpoint,omitempty"`
Stage string `json:"stage,omitempty"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
@@ -580,6 +582,10 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
if e.SpanID != "" {
summary.SpanID = e.SpanID
}
if e.Kind == "checkpoint" {
summary.Checkpoint = e.Status
summary.Stage = e.Name
}
if e.Error != "" {
summary.LastError = e.Error
}
+3 -2
View File
@@ -500,7 +500,8 @@ func TestListRunSummaries(t *testing.T) {
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "checkpoint", Name: "ask", Status: "failed"},
{Time: time.Unix(0, 5), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -523,7 +524,7 @@ func TestListRunSummaries(t *testing.T) {
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
t.Fatalf("unexpected run-a summary: %#v", got[0])
}
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 3 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].Checkpoint != "failed" || got[1].Stage != "ask" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
t.Fatalf("unexpected run-b summary: %#v", got[1])
}
}
+88 -11
View File
@@ -11,6 +11,8 @@ import (
)
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
type textToolCall struct {
ID string `json:"id"`
@@ -46,17 +48,14 @@ func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tool
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
allowed := map[string]bool{}
for _, tool := range tools {
allowed[tool.Name] = true
if tool.OriginalName != "" {
allowed[tool.OriginalName] = true
}
}
allowed := textToolNames(tools)
if len(allowed) == 0 {
return nil
}
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
@@ -65,6 +64,33 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
return nil
}
func textToolNames(tools []ai.Tool) map[string]string {
allowed := map[string]string{}
for _, tool := range tools {
addTextToolName(allowed, tool.Name, tool.Name)
if tool.OriginalName != "" {
addTextToolName(allowed, tool.OriginalName, tool.Name)
}
}
return allowed
}
func addTextToolName(allowed map[string]string, name, canonical string) {
if name == "" || canonical == "" {
return
}
allowed[name] = canonical
// Some OpenAI-compatible models describe an idempotent Add endpoint as a
// creation action and emit the otherwise-correct service tool with a Create
// suffix in text-only tool-call markup. Keep the fallback bounded by the
// offered service tool prefix so ordinary unknown tools remain ignored.
for _, suffix := range []string{"_Add", ".Add"} {
if strings.HasSuffix(name, suffix) {
allowed[strings.TrimSuffix(name, suffix)+strings.Replace(suffix, "Add", "Create", 1)] = canonical
}
}
}
func jsonCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
var out []string
@@ -76,13 +102,18 @@ func jsonCandidates(text string) []string {
out = append(out, strings.TrimSpace(match[1]))
}
}
for _, match := range taggedToolCallBlock.FindAllStringSubmatch(text, -1) {
if len(match) > 1 {
out = append(out, strings.TrimSpace(match[1]))
}
}
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
out = append(out, strings.TrimSpace(text[start:end+1]))
}
return out
}
func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCall {
func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolCall {
var root any
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
return nil
@@ -90,7 +121,7 @@ func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCal
return collectTextToolCalls(root, allowed)
}
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
switch x := v.(type) {
case []any:
var out []ai.ToolCall
@@ -111,19 +142,65 @@ func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
if input == nil {
input = call.Arguments
}
if name == "" || !allowed[name] || input == nil {
if name == "" || allowed[name] == "" || input == nil {
return nil
}
id := call.ID
if id == "" {
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
}
return []ai.ToolCall{{ID: id, Name: name, Input: input}}
return []ai.ToolCall{{ID: id, Name: allowed[name], Input: input}}
default:
return nil
}
}
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
if len(match) < 3 {
continue
}
tag, body := match[1], strings.TrimSpace(match[2])
if calls := decodeTextToolCalls(body, allowed); len(calls) > 0 {
out = append(out, calls...)
continue
}
if calls := decodeTaggedTextToolCalls(body, allowed); len(calls) > 0 {
out = append(out, calls...)
continue
}
name := taggedToolName(tag)
if name == "" || allowed[name] == "" {
continue
}
var input map[string]any
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
continue
}
out = append(out, ai.ToolCall{
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_")),
Name: allowed[name],
Input: input,
})
}
return out
}
func taggedToolName(tag string) string {
for _, marker := range []string{"function=", "name=", "tool="} {
if idx := strings.Index(tag, marker); idx >= 0 {
name := strings.TrimSpace(tag[idx+len(marker):])
name = strings.Trim(name, `"'`)
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
name = name[:end]
}
return strings.Trim(name, `"'`)
}
}
return ""
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
if v, ok := m[key]; ok {
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"testing"
"go-micro.dev/v6/ai"
)
func TestParseTextToolCallsMiniMaxTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
reply := `<tool_calls>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Design"}}</tool_call>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Build"}}</tool_call>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Ship"}}</tool_call>
</tool_calls>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 3 {
t.Fatalf("parseTextToolCalls returned %d calls, want 3: %+v", len(calls), calls)
}
for i, want := range []string{"Design", "Build", "Ship"} {
if calls[i].Name != "task_TaskService_Add" {
t.Fatalf("call %d name = %q, want task_TaskService_Add", i, calls[i].Name)
}
if got := calls[i].Input["title"]; got != want {
t.Fatalf("call %d title = %v, want %q", i, got, want)
}
}
}
func TestParseTextToolCallsFunctionTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
reply := `<function=task_TaskService_Add>{"title":"Design"}</function>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if got := calls[0].Input["title"]; got != "Design" {
t.Fatalf("title = %v, want Design", got)
}
}
func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add", OriginalName: "task.TaskService.Add"}}
reply := `<tool_call>{"name":"task_TaskService_Create","arguments":{"title":"Design"}}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "task_TaskService_Add" {
t.Fatalf("call name = %q, want canonical task_TaskService_Add", calls[0].Name)
}
if got := calls[0].Input["title"]; got != "Design" {
t.Fatalf("title = %v, want Design", got)
}
}
+21
View File
@@ -85,6 +85,12 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
fmt.Fprintf(w, " Agent %q runs\n", name)
for _, run := range runs {
fmt.Fprintf(w, " %s status=%s events=%d last=%s", run.RunID, run.Status, run.Events, run.LastKind)
if run.Checkpoint != "" {
fmt.Fprintf(w, " checkpoint=%s", run.Checkpoint)
}
if run.Stage != "" {
fmt.Fprintf(w, " stage=%s", run.Stage)
}
if run.LastError != "" {
fmt.Fprintf(w, " error=%q", run.LastError)
}
@@ -92,10 +98,25 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
}
fmt.Fprintln(w)
if isResumableAgentRun(run) {
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
}
if run.Stage == "input-required" {
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the paused run\n", run.RunID)
}
}
return nil
}
func isResumableAgentRun(run goagent.RunSummary) bool {
switch run.Status {
case "running", "error", "failed", "refused":
return run.Checkpoint != "done" || run.Stage != ""
default:
return false
}
}
func inspectFlow(c *cli.Context) error {
name := c.Args().First()
if name == "" {
+16 -2
View File
@@ -11,13 +11,27 @@ import (
)
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef"}}
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", `error="boom"`, "trace=1234567890ab"} {
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro.AgentResume(ctx, agent, "run-1")`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-input", Status: "running", Events: 3, LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
+5 -2
View File
@@ -15,7 +15,7 @@ of reading the directories alphabetically.
| Step | Start here | What you learn | Next step |
|------|------------|----------------|-----------|
| 1. First service | [`hello-world`](./hello-world/) | Create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
| 2. First agent | [`agent-demo`](./agent-demo/) | Run a small project-management app with Projects, Tasks, and Team services plus an agent playground. | Compare with the maintained 0-to-hero path in [`support`](./support/). |
| 2. First agent | [`first-agent`](./first-agent/) | Run the smallest service-backed agent with a deterministic mock model and no provider key. | Compare with [`agent-demo`](./agent-demo/) or the maintained 0-to-hero path in [`support`](./support/). |
| 3. First workflow | [`support`](./support/) | Follow typed services into an agent chat loop, an event-driven `intake` flow, and an approval gate in one runnable reference. | Deepen the workflow model with [`flow-durable`](./flow-durable/). |
For the shortest AI-tooling bridge, the MCP path is
@@ -75,8 +75,11 @@ Docker Compose deployment with MCP gateway, Consul registry, and Jaeger tracing:
### 2. Agents — turn services into tool-using teammates
#### [first-agent](./first-agent/)
Smallest first agent: one notes service plus one scoped agent, backed by a deterministic mock model so `go run ./examples/first-agent` works without provider secrets.
#### [agent-demo](./agent-demo/)
Recommended first agent: a multi-service project management app with Projects,
A multi-service project management app with Projects,
Tasks, and Team services, seed data, and agent playground integration.
#### [agent-plan-delegate](./agent-plan-delegate/)
+38
View File
@@ -0,0 +1,38 @@
# First Agent
This is the smallest runnable service-backed agent in the repository. It sits
between `micro new helloworld` and the full [`examples/support`](../support/)
0→hero reference.
It runs with a deterministic mock model, so you do not need `ANTHROPIC_API_KEY`,
`OPENAI_API_KEY`, or any other provider secret.
```bash
go run ./examples/first-agent
```
Expected transcript:
```text
First agent (provider: mock, no API key)
> Summarize my next steps
[notes] listed starter notes
assistant: Your first agent read the notes service and found three steps: install the CLI, run a service, then chat with an agent.
✓ service-backed agent completed without provider secrets
```
## What it demonstrates
- `notes` is a normal Go Micro service with one RPC method.
- `assistant` is an agent scoped to that service via `agent.Services("notes")`.
- The mock model requests the service tool through the normal agent tool handler.
- The final answer proves the service → agent path without a live model key.
CI keeps this path runnable with:
```bash
go test ./examples/first-agent
```
After this, continue to [`examples/support`](../support/) for the full services →
agents → workflows lifecycle with a flow trigger and an approval gate.
+156
View File
@@ -0,0 +1,156 @@
// First Agent — the smallest runnable service-backed agent.
//
// Run:
//
// go run ./examples/first-agent
//
// It uses a deterministic mock model, so it needs no provider API key. The
// point is to show the first agent shape: a service exposes a tool, an agent
// discovers that service, the model asks to call the tool, and the agent returns
// a final answer.
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
type ListNotesRequest struct{}
type ListNotesResponse struct {
Notes []string `json:"notes" description:"Notes the assistant can summarize"`
}
type NotesService struct{}
// List returns the starter notes the first agent can read.
// @example {}
func (s *NotesService) List(ctx context.Context, req *ListNotesRequest, rsp *ListNotesResponse) error {
rsp.Notes = []string{"Install the micro CLI", "Run a service", "Chat with an agent"}
fmt.Println(" [notes] listed starter notes")
return nil
}
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "first-agent-mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("stream not supported by first-agent mock")
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
for _, tool := range req.Tools {
if strings.Contains(tool.Name, "List") && m.opts.ToolHandler != nil {
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "list-notes", Name: tool.Name, Input: map[string]any{}})
break
}
}
return &ai.Response{Answer: "Your first agent read the notes service and found three steps: install the CLI, run a service, then chat with an agent."}, nil
}
func waitFor(reg registry.Registry, names ...string) error {
deadline := time.Now().Add(5 * time.Second)
for _, name := range names {
for {
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for %s", name)
}
time.Sleep(20 * time.Millisecond)
}
}
return nil
}
func runFirstAgent() error {
ai.Register("first-agent-mock", newMock)
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
if err := br.Init(); err != nil {
return fmt.Errorf("init broker: %w", err)
}
if err := br.Connect(); err != nil {
return fmt.Errorf("connect broker: %w", err)
}
defer br.Disconnect()
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))), client.Broker(br))
notes := service.New(service.Name("notes"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl), service.Broker(br), service.HandleSignal(false))
if err := notes.Handle(new(NotesService)); err != nil {
return fmt.Errorf("handle notes: %w", err)
}
svcErr := make(chan error, 1)
go func() { svcErr <- notes.Run() }()
defer notes.Server().Stop()
assistant := agent.New(
agent.Name("assistant"),
agent.Address("127.0.0.1:0"),
agent.Services("notes"),
agent.Prompt("You are a friendly first agent. Use the notes service before answering."),
agent.Provider("first-agent-mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
agent.WithBroker(br),
agent.WithStore(store.NewMemoryStore()),
)
agentErr := make(chan error, 1)
go func() { agentErr <- assistant.Run() }()
defer assistant.Stop()
if err := waitFor(reg, "notes", "assistant"); err != nil {
select {
case runErr := <-svcErr:
return fmt.Errorf("run notes: %w", runErr)
case runErr := <-agentErr:
return fmt.Errorf("run assistant: %w", runErr)
default:
}
return err
}
fmt.Println("First agent (provider: mock, no API key)")
fmt.Println("> Summarize my next steps")
resp, err := assistant.Ask(context.Background(), "Summarize my next steps")
if err != nil {
return fmt.Errorf("ask assistant: %w", err)
}
fmt.Println("assistant:", resp.Reply)
fmt.Println("✓ service-backed agent completed without provider secrets")
return nil
}
func main() {
if err := runFirstAgent(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
+9
View File
@@ -0,0 +1,9 @@
package main
import "testing"
func TestRunFirstAgent(t *testing.T) {
if err := runFirstAgent(); err != nil {
t.Fatalf("first-agent example failed: %v", err)
}
}
+3 -1
View File
@@ -131,6 +131,8 @@ const delegatedNotifyTask = "Use the notify Send tool exactly once to tell owner
const commsPrompt = "You handle outbound notifications. When asked to notify someone, you must call the notify Send tool exactly once before replying. Never claim a notification was sent unless the notify tool returned success."
const delegatedNotifySettleTimeout = 10 * time.Second
type SendRequest struct {
To string `json:"to" description:"Recipient address"`
Message string `json:"message" description:"Message body"`
@@ -464,7 +466,7 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
}
settled, err := waitForNotifySideEffect(notifySvc, 2*time.Second)
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if err != nil {
return err
}
@@ -17,6 +17,7 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
"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/first-agent -run TestRunFirstAgent -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",
@@ -93,6 +94,36 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
"internal/website/docs/guides/zero-to-hero.md",
},
},
{
name: "README examples list",
file: filepath.Join(root, "README.md"),
heading: "## Examples",
links: []string{
"examples/README.md",
"examples/first-agent/",
},
},
{
name: "repository examples index",
file: filepath.Join(root, "examples", "README.md"),
heading: "## Recommended first-agent path",
links: []string{
"./first-agent/",
"./support/",
},
},
{
name: "website examples index",
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
heading: "## Start here",
links: []string{
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
"../guides/no-secret-first-agent.html",
"../guides/your-first-agent.html",
"../guides/debugging-agents.html",
"../guides/zero-to-hero.html",
},
},
{
name: "website getting-started on-ramp",
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
@@ -129,6 +160,8 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "no-secret-first-agent.md"))
for _, want := range []string{
"go run ./examples/first-agent",
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
"go run ./examples/support",
"go test ./examples/support -run TestRunSupportMockSmoke -count=1",
"make harness",
@@ -169,6 +202,23 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
}
}
func TestFirstAgentWayfindingTargetsExist(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
for _, target := range []string{
"examples/README.md",
"examples/first-agent/README.md",
"internal/website/docs/examples/index.md",
"internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md",
"internal/website/docs/guides/zero-to-hero.md",
} {
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(target))); err != nil {
t.Fatalf("first-agent wayfinding target %s disappeared: %v", target, err)
}
}
}
func firstMarkdownSection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
+1
View File
@@ -14,5 +14,6 @@ go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscrip
# 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/first-agent -run TestRunFirstAgent -count=1
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
+9
View File
@@ -10,9 +10,12 @@ agents → workflows lifecycle.
## Start here
For the provider-free first-agent route, run [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent), then follow [No-secret First Agent](../guides/no-secret-first-agent.html), [Your First Agent](../guides/your-first-agent.html), [Debugging your agent](../guides/debugging-agents.html), and the [0→hero Reference](../guides/zero-to-hero.html).
| Goal | Runnable example | Why it is useful |
| --- | --- | --- |
| 0→1 service | [`examples/hello-world`](https://github.com/micro/go-micro/tree/master/examples/hello-world) | Smallest RPC service with a client call and health checks. |
| Provider-free first agent | [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent) | Smallest service-backed agent with a deterministic mock model; no provider key required. |
| First service-backed agent | [`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo) | Multi-service project/task/team app with agent playground integration. |
| 0→hero lifecycle | [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) | No-secret support-desk story: typed services, an agent, an event-driven flow, and a guardrail. |
| Planning and delegation | [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) | Two agents collaborate through `plan` and `delegate` over normal Go Micro RPC. |
@@ -25,10 +28,16 @@ agents → workflows lifecycle.
- [Getting Started](../getting-started.html) → run
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
to see the full lifecycle before generating your own service.
- [No-secret First Agent](../guides/no-secret-first-agent.html) → run
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
first for the smallest provider-free agent transcript.
- [Your First Agent](../guides/your-first-agent.html) → run
[`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo)
or [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
when you want a complete service-backed agent to inspect.
- [Debugging your agent](../guides/debugging-agents.html) → keep
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
nearby as the smallest mock-model reproduction before inspecting richer runs.
- [0→hero Reference](../guides/zero-to-hero.html) → run
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
for the human-readable scenario, then `make harness` for the full CI contract.
+5 -4
View File
@@ -90,10 +90,11 @@ The console discovers services from the registry and orchestrates across them vi
After this quick start, follow the agent path in order:
1. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
2. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
3. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
4. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
1. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
2. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
3. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
4. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
5. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
## Quick Start: Write a Service
@@ -25,11 +25,17 @@ end to end with no secrets.
## Transcript
From a fresh clone of the repository:
From a fresh clone of the repository, first run the smallest service-backed agent:
```sh
git clone https://github.com/micro/go-micro.git
cd go-micro
go run ./examples/first-agent
```
Then run the maintained support-agent transcript that exercises the full lifecycle:
```sh
go run ./examples/support
```
@@ -56,9 +62,10 @@ trigger and inspect the work.
## CI-backed check
Run the same deterministic path as a focused test:
Run the same deterministic paths as focused tests:
```sh
go test ./examples/first-agent -run TestRunFirstAgent -count=1
go test ./examples/support -run TestRunSupportMockSmoke -count=1
```
+14 -1
View File
@@ -24,12 +24,19 @@ cloud credentials?"
| 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` |
| Smallest first agent | `examples/first-agent` runs one service-backed agent with a deterministic mock model and no provider key. | `go test ./examples/first-agent -run TestRunFirstAgent -count=1` |
| 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
From the repository root, start with the support-desk example when you want to see the full lifecycle in one terminal:
From the repository root, start with the smallest service-backed agent when you want the fastest no-secret success path:
```sh
go run ./examples/first-agent
```
Then run the support-desk example when you want to see the full lifecycle in one terminal:
```sh
go run ./examples/support
@@ -68,6 +75,9 @@ 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
# Smallest no-secret service-backed first agent.
go test ./examples/first-agent -run TestRunFirstAgent -count=1
# Maintained 0→hero support-desk reference app.
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
@@ -83,6 +93,9 @@ make provider-conformance-mock
## Reference scenarios
- [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
is the smallest no-secret service-backed agent: one notes service, one scoped
assistant agent, and a deterministic mock model.
- [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
is the runnable support-desk story: customers, tickets, notify, a support
agent, an intake flow, and an approval gate in one no-secret example.