Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2a09635f6 |
@@ -1,8 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a vulnerability
|
||||
url: https://github.com/micro/go-micro/security/advisories/new
|
||||
about: Privately disclose security vulnerabilities to the maintainers.
|
||||
- name: 💖 Sponsor Go Micro
|
||||
url: https://github.com/sponsors/asim
|
||||
about: Fund ongoing development and see your name or logo on the project.
|
||||
|
||||
@@ -21,6 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Surface a no-secret first-agent demo command** ([#4036](https://github.com/micro/go-micro/issues/4036)) — The previous top adoption/operability item shipped in #4034 and closed #4031/#4033 by adding `micro agent doctor` for scaffold → run → chat → inspect recovery. The remaining open `codex` PR is #4022, a human-review blog/changelog draft, so it should not occupy the autonomous builder queue. With the README, website roadmap, and blog all telling the same story — agents are services on one runtime, and the first developer success path matters as much as hardening — the next highest-value gap is CLI discoverability before a user even knows which repository example or doc to open. A provider-free `micro` affordance that surfaces the maintained first-agent/support demo path keeps the on-ramp walkable from the installed binary, links the no-secret path to live-provider chat and inspect/debugging docs, and is CI-verifiable without API keys or broad API changes.
|
||||
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._
|
||||
|
||||
@@ -16,27 +16,6 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [6.3.15] - July 2026
|
||||
|
||||
### Added
|
||||
- **Anthropic streaming** — the Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider, with capability docs and parser coverage. (`ai/anthropic/`, `internal/website/docs/guides/`)
|
||||
- **AP2 mandate foundation for A2A** — the A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. (`gateway/a2a/`)
|
||||
- **Smallest first-agent example** — a no-secret, mock-model first-agent example gives the on-ramp a minimal runnable starting point. (`examples/first-agent/`)
|
||||
|
||||
### Changed
|
||||
- **First-agent CLI next steps** — CLI output now points new users toward the maintained first-agent path after scaffold/run milestones. (`cmd/micro/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — plan-delegate runs now preserve completed steps, guard ordering, require notify-before-completion, and stabilize checkpoint continuation paths. (`agent/`, `internal/harness/`)
|
||||
- **Provider text tool calls** — AtlasCloud and weaker-model fallback paths now recover tagged, `Create`-suffixed, mixed text/tool-call, and follow-up tool calls more reliably. (`agent/`, `ai/atlascloud/`)
|
||||
- **First-agent broker isolation** — the first-agent harness now isolates broker state more reliably across runs. (`internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent example path** — docs and website wayfinding now surface the smallest example, no-secret transcript, and 0→hero path together. (`README.md`, `internal/website/docs/`)
|
||||
- **Agent operations guidance** — agent debugging docs now include operational failure guidance, inspect hints, and durable resume pointers. (`internal/website/docs/guides/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.14] - July 2026
|
||||
|
||||
### Added
|
||||
|
||||
+4
-3
@@ -17,10 +17,10 @@ We actively support the following versions of go-micro:
|
||||
|
||||
### How to Report
|
||||
|
||||
Use GitHub's private security advisory feature:
|
||||
https://github.com/micro/go-micro/security/advisories/new
|
||||
Send security vulnerability reports to: **security@go-micro.dev**
|
||||
|
||||
This keeps vulnerability reports private, ties follow-up to the affected repository, and avoids relying on project email routing.
|
||||
Or use GitHub's private security advisory feature:
|
||||
https://github.com/micro/go-micro/security/advisories/new
|
||||
|
||||
### What to Include
|
||||
|
||||
@@ -175,4 +175,5 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
|
||||
For security questions that are not vulnerabilities, please:
|
||||
- Open a discussion: https://github.com/micro/go-micro/discussions
|
||||
- Join Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
+2
-15
@@ -297,11 +297,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
}
|
||||
}
|
||||
|
||||
// Some providers satisfy a saved plan one outstanding item per turn,
|
||||
// especially when the final item delegates to another agent. Allow enough
|
||||
// continuations for the services → agents → workflows harness to complete
|
||||
// every planned side effect without weakening the final unfinished-plan guard.
|
||||
const maxPlanCompletionTurns = 6
|
||||
const maxPlanCompletionTurns = 3
|
||||
var resp *ai.Response
|
||||
for planCompletionTurn := 0; ; planCompletionTurn++ {
|
||||
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
|
||||
@@ -356,15 +352,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
resp.Reply = ""
|
||||
}
|
||||
}
|
||||
} else if calls, answer, ok := a.executeAdditionalTextToolCalls(ctx, resp.Reply, toolList, resp.ToolCalls); ok {
|
||||
resp.ToolCalls = append(resp.ToolCalls, calls...)
|
||||
if answer != "" {
|
||||
if resp.Answer == "" {
|
||||
resp.Answer = answer
|
||||
} else {
|
||||
resp.Answer += "\n" + answer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if a.opts.Checkpoint != nil {
|
||||
@@ -375,7 +362,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
if resp.Answer != "" {
|
||||
a.mem.Add("assistant", resp.Answer)
|
||||
}
|
||||
message = fmt.Sprintf("Continue the same run by calling the required tool(s) for the unfinished plan steps below. Do not repeat completed work, do not provide a final answer yet, and complete at least one unfinished step this turn if a matching tool is available. Unfinished plan steps: %s", strings.Join(unfinished, ", "))
|
||||
message = "Continue the run. These plan steps are still unfinished and must be completed before a final answer: " + strings.Join(unfinished, ", ")
|
||||
a.mem.Add("user", message)
|
||||
messages = a.mem.Messages()
|
||||
continue
|
||||
|
||||
+1
-53
@@ -290,11 +290,6 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if call.Name == toolPlan {
|
||||
return a.handlePlan(call)
|
||||
}
|
||||
if call.Name == toolDelegate {
|
||||
if blocked := a.unfinishedPlanStepsBeforeDelegation(); len(blocked) > 0 {
|
||||
return refused(call.ID, ai.RefusedApproval, "complete these plan steps before delegating: "+strings.Join(blocked, ", "))
|
||||
}
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused == "" && toolErrorMessage(res) == "" {
|
||||
a.completeNextPlanStep()
|
||||
@@ -469,53 +464,6 @@ func (a *agentImpl) completeNextPlanStep() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanStepsBeforeDelegation() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var unfinished []string
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task := planStepTask(step)
|
||||
if isDelegationPlanTask(task) {
|
||||
break
|
||||
}
|
||||
if !isUnfinishedPlanStatus(step["status"]) {
|
||||
continue
|
||||
}
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
unfinished = append(unfinished, task)
|
||||
}
|
||||
return unfinished
|
||||
}
|
||||
|
||||
func planStepTask(step map[string]any) string {
|
||||
if task, _ := step["task"].(string); task != "" {
|
||||
return task
|
||||
}
|
||||
desc, _ := step["description"].(string)
|
||||
return desc
|
||||
}
|
||||
|
||||
func isDelegationPlanTask(task string) bool {
|
||||
task = normalizePlanTask(task)
|
||||
return strings.Contains(task, "delegate") || strings.Contains(task, "notify") || strings.Contains(task, "notification")
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
@@ -539,7 +487,7 @@ func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
if status != "" && status != "pending" && status != "in_progress" {
|
||||
continue
|
||||
}
|
||||
task := planStepTask(step)
|
||||
task, _ := step["task"].(string)
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
@@ -192,45 +191,3 @@ func TestIsAgent(t *testing.T) {
|
||||
t.Error("isAgent(nonexistent) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrapBlocksDelegationUntilPriorPlanStepsFinish(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "Create Design task", "status": "pending"},
|
||||
map[string]any{"task": "Create Build task", "status": "pending"},
|
||||
map[string]any{"task": "Create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
|
||||
called := false
|
||||
handle := a.planWrap(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
called = true
|
||||
return ai.ToolResult{ID: call.ID, Content: "ok"}
|
||||
})
|
||||
|
||||
res := handle(context.Background(), ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if called {
|
||||
t.Fatal("delegate handler was called before prior task plan steps completed")
|
||||
}
|
||||
if res.Refused == "" {
|
||||
t.Fatalf("delegate result was not refused: %+v", res)
|
||||
}
|
||||
if got := res.Content; !containsStr(got, "Create Design task") || !containsStr(got, "Create Ship task") {
|
||||
t.Fatalf("delegate refusal content = %q, want prior unfinished task steps", got)
|
||||
}
|
||||
|
||||
for _, id := range []string{"add-design", "add-build", "add-ship"} {
|
||||
_ = handle(context.Background(), ai.ToolCall{ID: id, Name: "task.Add", Input: map[string]any{"title": id}})
|
||||
}
|
||||
called = false
|
||||
res = handle(context.Background(), ai.ToolCall{ID: "delegate-2", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if !called {
|
||||
t.Fatal("delegate handler was not called after prior task plan steps completed")
|
||||
}
|
||||
if res.Refused != "" {
|
||||
t.Fatalf("delegate result refused after prior task steps completed: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,72 +219,6 @@ func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointContinuesRunThroughSeveralSingleStepTurns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "single-step-plan-agent")
|
||||
|
||||
completed := []string{}
|
||||
modelCalls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
modelCalls++
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
switch modelCalls {
|
||||
case 1:
|
||||
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"},
|
||||
map[string]any{"task": "create Build task", "status": "pending"},
|
||||
map[string]any{"task": "create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "delegate readiness notification", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
return &ai.Response{Reply: "planned"}, nil
|
||||
case 2, 3, 4, 5:
|
||||
want := []string{"create Design task", "create Build task", "create Ship task", "delegate readiness notification"}[modelCalls-2]
|
||||
if !strings.Contains(req.Prompt, want) {
|
||||
t.Fatalf("continuation prompt %d = %q, want %q", modelCalls, req.Prompt, want)
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: want, Name: "external.step", Input: map[string]any{"step": want}})
|
||||
if res.Content != "completed "+want {
|
||||
t.Fatalf("tool result = %q, want completed %s", res.Content, want)
|
||||
}
|
||||
if modelCalls == 5 {
|
||||
return &ai.Response{Reply: "all plan steps complete"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "one more step complete"}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected model call %d", modelCalls)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("single-step-plan-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.step", "complete one planned step", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
step, _ := input["step"].(string)
|
||||
completed = append(completed, step)
|
||||
return "completed " + step, nil
|
||||
}))
|
||||
resp, err := a.Ask(ctx, "work through the launch plan")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.Reply != "all plan steps complete" {
|
||||
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
|
||||
}
|
||||
if modelCalls != 5 {
|
||||
t.Fatalf("model calls = %d, want initial plus four continuations", modelCalls)
|
||||
}
|
||||
if len(completed) != 4 {
|
||||
t.Fatalf("completed steps = %v, want four tool-backed continuations", completed)
|
||||
}
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
|
||||
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
|
||||
@@ -504,67 +504,3 @@ func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesTextToolCallFallbackAfterStructuredToolCall(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "structured-echo-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: echo.Content + "\n<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "structured-echo-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-mixed-text-tool"),
|
||||
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 := a.Ask(context.Background(), "Run the mixed structured/text tool fallback.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("structured conformance_echo did not execute")
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("tagged text delegate fallback did not execute")
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want structured echo and text delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("delegate ToolCall = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -215,13 +215,13 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
e.ErrorKind = string(ai.ClassifyError(err))
|
||||
}
|
||||
m.a.recordSpanEvent(span, e)
|
||||
span.End()
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -393,8 +393,8 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
span.End()
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
+2
-13
@@ -95,16 +95,8 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
|
||||
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
|
||||
}
|
||||
if s.Name() == spanNameModelCall {
|
||||
if attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1" {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.model", runID, "runner") {
|
||||
t.Fatalf("model span missing model event: %#v", s.Events())
|
||||
}
|
||||
}
|
||||
if s.Name() == spanNameToolCall && !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
|
||||
t.Fatalf("tool span missing tool event: %#v", s.Events())
|
||||
if s.Name() == spanNameModelCall && (attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1") {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
@@ -663,9 +655,6 @@ func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
|
||||
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
|
||||
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.stream", "stream-run-1", "stream-runner") {
|
||||
t.Fatalf("stream span missing stream event: %#v", s.Events())
|
||||
}
|
||||
sawStream = true
|
||||
}
|
||||
if !sawStream {
|
||||
|
||||
@@ -47,48 +47,6 @@ func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tool
|
||||
return calls, strings.Join(results, "\n"), true
|
||||
}
|
||||
|
||||
// executeAdditionalTextToolCalls runs text-encoded tool calls that accompany a
|
||||
// structured tool_calls response. Some OpenAI-compatible providers can mix the
|
||||
// two forms in a single assistant turn: for example, emitting a native
|
||||
// conformance_echo call while rendering a follow-up guarded delegate call as
|
||||
// <tool_call name="delegate">...</tool_call> text. Keep this fallback additive
|
||||
// and de-duplicate calls already represented in the structured tool_calls list.
|
||||
func (a *agentImpl) executeAdditionalTextToolCalls(ctx context.Context, reply string, tools []ai.Tool, existing []ai.ToolCall) ([]ai.ToolCall, string, bool) {
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, call := range existing {
|
||||
seen[textToolCallKey(call)] = true
|
||||
}
|
||||
|
||||
handler := a.toolHandler()
|
||||
out := make([]ai.ToolCall, 0, len(calls))
|
||||
results := make([]string, 0, len(calls))
|
||||
for i := range calls {
|
||||
if seen[textToolCallKey(calls[i])] {
|
||||
continue
|
||||
}
|
||||
result := handler(ctx, calls[i])
|
||||
calls[i].Result = result.Content
|
||||
if result.Refused != "" {
|
||||
calls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
results = append(results, result.Content)
|
||||
}
|
||||
out = append(out, calls[i])
|
||||
}
|
||||
return out, strings.Join(results, "\n"), len(out) > 0
|
||||
}
|
||||
|
||||
func textToolCallKey(call ai.ToolCall) string {
|
||||
b, _ := json.Marshal(call.Input)
|
||||
return call.Name + "\x00" + string(b)
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
allowed := textToolNames(tools)
|
||||
if len(allowed) == 0 {
|
||||
|
||||
+2
-108
@@ -2,7 +2,6 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("anthropic")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
@@ -158,113 +156,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response from Anthropic's Messages SSE API.
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": threadAnthropicMessages(req),
|
||||
"stream": true,
|
||||
}
|
||||
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, "/") + "/v1/messages"
|
||||
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")
|
||||
httpReq.Header.Set("x-api-key", p.opts.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
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 &streamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type streamReader struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *streamReader) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
var chunk struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
Message struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
Usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
switch chunk.Type {
|
||||
case "content_block_delta":
|
||||
if chunk.Delta.Type == "text_delta" && chunk.Delta.Text != "" {
|
||||
return &ai.Response{Reply: chunk.Delta.Text}, nil
|
||||
}
|
||||
case "message_start":
|
||||
if chunk.Message.Usage.InputTokens > 0 || chunk.Message.Usage.OutputTokens > 0 {
|
||||
return &ai.Response{Usage: usage(chunk.Message.Usage.InputTokens, chunk.Message.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_delta":
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: usage(chunk.Usage.InputTokens, chunk.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_stop":
|
||||
return nil, io.EOF
|
||||
case "error":
|
||||
return nil, fmt.Errorf("anthropic stream error: %s", data)
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *streamReader) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func usage(input, output int) ai.Usage {
|
||||
return ai.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output}
|
||||
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
|
||||
@@ -3,10 +3,6 @@ package anthropic
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -85,67 +81,15 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
t.Fatalf("path = %q, want /v1/messages", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Accept"); got != "text/event-stream" {
|
||||
t.Fatalf("Accept = %q, want text/event-stream", got)
|
||||
}
|
||||
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
||||
t.Fatalf("x-api-key = %q, want test-key", got)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"stream":true`) {
|
||||
t.Fatalf("request body %s does not enable streaming", string(body))
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("event: message_start\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":2}}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hel"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_delta","usage":{"output_tokens":3}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_stop\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_stop"}` + "\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
stream, err := p.Stream(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var reply strings.Builder
|
||||
var usage ai.Usage
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Recv failed: %v", err)
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
if chunk.Usage.TotalTokens > 0 {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
}
|
||||
if got := reply.String(); got != "hello" {
|
||||
t.Fatalf("reply = %q, want hello", got)
|
||||
}
|
||||
if usage.TotalTokens != 3 {
|
||||
t.Fatalf("usage = %+v, want total 3", usage)
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
|
||||
var toolResults []string
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
@@ -164,32 +163,11 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
// Keep the tool schema available during the follow-up turn. Minimax
|
||||
// models behind Atlas Cloud sometimes call one required tool, inspect
|
||||
// that result, and then issue a second tool call (for example a guarded
|
||||
// delegate conformance check) instead of completing immediately.
|
||||
followUpReq["tools"] = tools
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
for i := range followUpResp.ToolCalls {
|
||||
result := p.opts.ToolHandler(ctx, followUpResp.ToolCalls[i])
|
||||
if result.Refused != "" {
|
||||
followUpResp.ToolCalls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
followUpResp.ToolCalls[i].Result = result.Content
|
||||
toolResults = append(toolResults, result.Content)
|
||||
}
|
||||
}
|
||||
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
|
||||
resp.ToolCalls = allToolCalls
|
||||
}
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
} else if len(toolResults) > 0 {
|
||||
|
||||
@@ -289,71 +289,6 @@ func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-2","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}]}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var sawEcho, sawDelegate bool
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
switch call.Name {
|
||||
case "conformance_echo":
|
||||
sawEcho = true
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
case "delegate":
|
||||
sawDelegate = true
|
||||
return ai.ToolResult{ID: call.ID, Refused: ai.RefusedApproval, Content: "blocked by policy"}
|
||||
default:
|
||||
t.Fatalf("unexpected tool call %+v", call)
|
||||
return ai.ToolResult{}
|
||||
}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "run conformance",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if !sawEcho || !sawDelegate {
|
||||
t.Fatalf("sawEcho=%v sawDelegate=%v, want both tools executed", sawEcho, sawDelegate)
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want echo and delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("follow-up delegate = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
if !strings.Contains(resp.Answer, "blocked by policy") {
|
||||
t.Fatalf("Answer = %q, want follow-up tool result", resp.Answer)
|
||||
}
|
||||
if _, ok := bodies[1]["tools"].([]any); !ok {
|
||||
t.Fatalf("follow-up request did not include tools: %#v", bodies[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
func TestCapabilityRows(t *testing.T) {
|
||||
got := ai.CapabilityRows()
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
@@ -90,7 +90,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,6 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
|
||||
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
|
||||
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
|
||||
{provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY", modelEnv: "ANTHROPIC_MODEL"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
@@ -257,7 +256,7 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"gemini"} {
|
||||
for _, provider := range []string{"anthropic", "gemini"} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities(provider); caps.Stream {
|
||||
|
||||
@@ -37,22 +37,13 @@ func init() {
|
||||
Usage: "Manage AI agents",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "preflight",
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Name: "preflight",
|
||||
Aliases: []string{"doctor"},
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "doctor",
|
||||
Usage: "Diagnose chat and inspect recovery after micro run",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type doctorDeps struct {
|
||||
getenv func(string) string
|
||||
httpGet func(string) (*http.Response, error)
|
||||
listServices func() ([]*registry.Service, error)
|
||||
getService func(string) ([]*registry.Service, error)
|
||||
listRuns func(string) ([]goagent.RunSummary, error)
|
||||
}
|
||||
|
||||
func defaultDoctorDeps() doctorDeps {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
return doctorDeps{
|
||||
getenv: defaultPreflightDeps().getenv,
|
||||
httpGet: client.Get,
|
||||
listServices: registry.ListServices,
|
||||
getService: registry.GetService,
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
|
||||
if gateway == "" {
|
||||
gateway = "http://localhost:8080"
|
||||
}
|
||||
gateway = strings.TrimRight(gateway, "/")
|
||||
checks := agentDoctorChecks(deps, gateway)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent recovery doctor")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
if !check.OK && check.Next != "" {
|
||||
fmt.Fprintf(w, " Next: %s\n", check.Next)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = defaultPreflightDeps().getenv
|
||||
}
|
||||
if deps.httpGet == nil {
|
||||
deps.httpGet = http.Get
|
||||
}
|
||||
if deps.listServices == nil {
|
||||
deps.listServices = registry.ListServices
|
||||
}
|
||||
if deps.getService == nil {
|
||||
deps.getService = registry.GetService
|
||||
}
|
||||
if deps.listRuns == nil {
|
||||
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
}
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
|
||||
agents, regCheck := checkAgentRegistration(deps)
|
||||
checks = append(checks, regCheck)
|
||||
checks = append(checks, checkRunHistory(deps, agents))
|
||||
checks = append(checks, checkProviderConfig(deps))
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/agent")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
|
||||
}
|
||||
|
||||
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/api/agent/settings")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
|
||||
}
|
||||
var settings map[string]string
|
||||
_ = json.NewDecoder(resp.Body).Decode(&settings)
|
||||
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
|
||||
}
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
|
||||
}
|
||||
|
||||
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
|
||||
services, err := deps.listServices()
|
||||
if err != nil {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
|
||||
}
|
||||
var agents []string
|
||||
for _, svc := range services {
|
||||
records, err := deps.getService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
if serviceIsAgent(records[0]) {
|
||||
agents = append(agents, svc.Name)
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
|
||||
}
|
||||
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
|
||||
}
|
||||
|
||||
func serviceIsAgent(svc *registry.Service) bool {
|
||||
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
for _, node := range svc.Nodes {
|
||||
if node.Metadata != nil && node.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
|
||||
if len(agents) == 0 {
|
||||
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
for _, name := range agents {
|
||||
runs, err := deps.listRuns(name)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
|
||||
}
|
||||
}
|
||||
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
|
||||
}
|
||||
|
||||
func checkProviderConfig(deps doctorDeps) preflightCheck {
|
||||
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
|
||||
check.Name = "provider configuration"
|
||||
if !check.OK {
|
||||
check.Detail = "no provider key found for live LLM chat"
|
||||
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
|
||||
}
|
||||
return check
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
|
||||
return func(string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(key string) string {
|
||||
if key == "MICRO_AI_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "assistant"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
|
||||
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(string) string { return "" },
|
||||
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "greeter"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := runAgentDoctor(&out, deps, "http://localhost:8080")
|
||||
if err == nil {
|
||||
t.Fatal("runAgentDoctor() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,10 @@ const docsWayfinding = `First-agent and 0→hero docs:
|
||||
micro agent preflight
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor
|
||||
|
||||
3. Debugging your agent
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
Inspect agent runs and memory with:
|
||||
micro agent doctor
|
||||
micro inspect agent
|
||||
micro runs <agent>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
@@ -58,43 +57,6 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
generated.call(t, "Bob", "Hello Bob")
|
||||
}
|
||||
|
||||
func TestPrintNextStepsSurfacesFirstAgentPath(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "helloworld", false)
|
||||
|
||||
for _, want := range []string{
|
||||
"cd helloworld",
|
||||
"micro agent preflight",
|
||||
"go run .",
|
||||
"micro chat",
|
||||
"micro inspect agent",
|
||||
"micro docs",
|
||||
"your-first-agent.html",
|
||||
"zero-to-hero.html",
|
||||
"http://localhost:3001/mcp/tools",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintNextStepsNoMCPSkipsMCPHints(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "worker", true)
|
||||
|
||||
for _, want := range []string{"micro agent preflight", "micro chat", "micro inspect agent", "micro docs"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("--no-mcp next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{"http://localhost:3001/mcp/tools", "micro mcp serve"} {
|
||||
if strings.Contains(out.String(), notWant) {
|
||||
t.Fatalf("--no-mcp next steps should not include %q:\n%s", notWant, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
dir string
|
||||
repoRoot string
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/build"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -281,28 +280,16 @@ func Run(ctx *cli.Context) error {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
|
||||
printNextSteps(os.Stdout, dir, noMCP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printNextSteps(w io.Writer, dir string, noMCP bool) {
|
||||
fmt.Fprintln(w, " Next steps:")
|
||||
fmt.Fprintf(w, " cd %s\n", dir)
|
||||
fmt.Fprintln(w, " micro agent preflight")
|
||||
fmt.Fprintln(w, " go run .")
|
||||
fmt.Fprintln(w, " micro chat")
|
||||
fmt.Fprintln(w, " micro inspect agent")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, " First-agent path:")
|
||||
fmt.Fprintln(w, " micro docs")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/your-first-agent.html")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/zero-to-hero.html")
|
||||
fmt.Println(" Next steps:")
|
||||
fmt.Printf(" cd %s\n", dir)
|
||||
fmt.Println(" go run .")
|
||||
if !noMCP {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Fprintln(w, " Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Println(" Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
|
||||
|
||||
@@ -30,9 +30,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if !subcommands["agent"]["preflight"] {
|
||||
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
|
||||
}
|
||||
if !subcommands["agent"]["doctor"] {
|
||||
t.Fatal("first-agent walkthrough missing recovery boundary: agent doctor")
|
||||
}
|
||||
if !subcommands["inspect"]["agent"] {
|
||||
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
|
||||
}
|
||||
@@ -60,7 +57,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
"micro agent preflight",
|
||||
"micro run",
|
||||
"micro chat",
|
||||
"micro agent doctor",
|
||||
"micro inspect agent",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
|
||||
@@ -403,14 +403,8 @@ func runPlanDelegate(provider string) error {
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Steps(
|
||||
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor)},
|
||||
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
})},
|
||||
),
|
||||
flow.WithCheckpoint(flow.StoreCheckpoint(mem, "flow-zero-to-hero")),
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
flow.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
@@ -425,8 +419,15 @@ func runPlanDelegate(provider string) error {
|
||||
executeDone <- f.Execute(ctx, "launch readiness")
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rs[len(rs)-1].Reply)
|
||||
}
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
@@ -443,48 +444,7 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
|
||||
rsp, err := conductor.Ask(ctx, prompt)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if rsp != nil && rsp.Reply != "" {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if notify == 1 {
|
||||
return in, nil
|
||||
}
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m conductor step completed before delegated notify; retrying the missing comms handoff once before the flow can complete.\n")
|
||||
if err := recoverMissingNotify(ctx); err != nil {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, err)
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService) error {
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -503,7 +463,25 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
|
||||
}
|
||||
if notify != 1 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
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, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
retryErr := recoverMissingNotify(ctx)
|
||||
cancel()
|
||||
if retryErr != nil {
|
||||
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
|
||||
@@ -258,7 +258,7 @@ func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T)
|
||||
|
||||
done := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc) }()
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
@@ -278,7 +278,7 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc)
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
|
||||
}
|
||||
@@ -296,13 +296,15 @@ func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})(context.Background(), flow.State{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
|
||||
}
|
||||
@@ -323,6 +325,8 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
@@ -331,11 +335,11 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}()
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})(context.Background(), flow.State{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want in-flight notify success", err)
|
||||
}
|
||||
@@ -370,7 +374,7 @@ func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T)
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
|
||||
}
|
||||
}
|
||||
@@ -379,7 +383,7 @@ func TestPlanDelegateExecutionClassifiesClientTimeoutBeforeSideEffects(t *testin
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService))
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
|
||||
}
|
||||
@@ -406,7 +410,7 @@ func TestPlanDelegateExecutionClassifiesPartialClientTimeout(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService))
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before notify to fail")
|
||||
}
|
||||
|
||||
@@ -5,29 +5,27 @@ title: AI Integration
|
||||
|
||||
# AI Integration
|
||||
|
||||
Go Micro is an agent harness and service framework for Go. Every service you build can become an AI-callable tool, every agent runs as a service with model/memory/guardrails around it, and flows orchestrate the deterministic parts. This page explains how the services → agents → workflows lifecycle fits together.
|
||||
Go Micro is an AI-native microservices framework. Every service you build is automatically accessible to AI agents, and every service can call AI models. This page explains how the pieces fit together.
|
||||
|
||||
<img src="/images/generated/mcp-agent.jpg" alt="AI integration architecture" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
|
||||
|
||||
## The Stack
|
||||
|
||||
```
|
||||
Services → write Go handlers, register with the framework
|
||||
Your Services → write Go handlers, register with the framework
|
||||
↓
|
||||
Registry → automatic discovery for services, agents, and flows
|
||||
Registry → automatic service discovery (mDNS, Consul, etcd)
|
||||
↓
|
||||
Gateways → micro api (HTTP→RPC), micro mcp (tools), micro a2a (agents)
|
||||
Gateways → micro api (HTTP→RPC) / micro mcp (MCP tools)
|
||||
↓
|
||||
ai.Tools → discovers services + executes RPCs programmatically
|
||||
↓
|
||||
ai.Model → calls LLMs (Anthropic, OpenAI, Gemini, Atlas Cloud, ...)
|
||||
↓
|
||||
Agents → service-backed model loop with memory, guardrails, plan/delegate
|
||||
↓
|
||||
Flows → durable deterministic steps that can dispatch to agents
|
||||
agent / flow / micro chat → agent-managed, event-driven, or interactive orchestration
|
||||
```
|
||||
|
||||
Every layer is optional. You can use Go Micro as a service framework without AI. You can use the `ai` package without MCP. But when you stack them, you get one runtime where services become tools, agents are reachable services, and workflows coordinate the predictable parts.
|
||||
Every layer is optional. You can use go-micro without AI. You can use the `ai` package without MCP. But when you stack them, you get services that AI agents can discover and orchestrate automatically.
|
||||
|
||||
## Layer by Layer
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The built-in providers currently register these capability interfaces:
|
||||
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | Yes |
|
||||
| `anthropic` | Yes | No | No | No |
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
|
||||
@@ -57,7 +57,7 @@ previous section.
|
||||
|
||||
| Provider | Chat/text agent harness | Image | Video | Streaming | Structured errors |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ✅ Verified when configured | ⚠️ Unverified |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `openai` | ✅ Verified when configured | ✅ Registered | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `groq` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
|
||||
@@ -39,10 +39,9 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
|
||||
You now have the service half of the services → agents → workflows lifecycle running locally. Keep the on-ramp going in this order:
|
||||
|
||||
1. **[Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)** - run a mock-model, no-secret agent before adding provider keys.
|
||||
2. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight` → `micro run` → `micro chat` loop.
|
||||
3. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
|
||||
4. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
|
||||
1. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight` → `micro run` → `micro chat` loop.
|
||||
2. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
|
||||
3. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
|
||||
|
||||
After that first-agent path, branch out to:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user