Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 5dc851a676 Harden agent conformance retry completion
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-06 22:09:47 +00:00
25 changed files with 99 additions and 1121 deletions
+2 -3
View File
@@ -21,9 +21,8 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Make AtlasCloud plan-delegate complete the required notify side effect** ([#4255](https://github.com/micro/go-micro/issues/4255)) — Now-phase getting-started contract and cross-provider conformance overlap here: the live AtlasCloud 0→hero plan/delegate harness can now parse the initial plan call, but still exits before the required task and notify side effects, so a provider-backed services → agents → workflows path is not yet reliable.
2. **Link examples wayfinding from website getting-started path** ([#4241](https://github.com/micro/go-micro/issues/4241)) — keep adoption weighted with hardening after the examples index shipped: the repo README and CLI now point at the first-agent/0→hero map, but go-micro.dev getting-started and quickstart pages still skip the examples index/support reference links that make the no-secret on-ramp discoverable.
3. **Make AtlasCloud guarded delegation pass reliably** ([#4244](https://github.com/micro/go-micro/issues/4244)) — Now-phase cross-provider conformance remains important after the Minimax request-shape fallback, duplicate delegated-notification replay fixes, and OpenAI-compatible text tool-call parsing shipped; keep it in queue until the live agent harness consistently observes the guarded delegate within the retry budget.
1. **Harden AtlasCloud guarded-delegate conformance** ([#4183](https://github.com/micro/go-micro/issues/4183)) — #4185 closed the duplicate delegated notification gap from #4163, and the latest live provider run now fails one step earlier in the same Now-phase adoption seam: AtlasCloud completes the required echo tool call but does not reliably exercise the refused guarded `delegate` path. Make the conformance prompt/retry/fallback path require both tool calls before accepting a final answer so the scheduled evaluator reflects real services → agents → workflows behavior instead of a provider-specific shortcut.
2. **Propagate cancellation and retry signals through provider model calls** ([#4175](https://github.com/micro/go-micro/issues/4175)) — once the live conformance harness again proves the guarded delegate path, the next Now-phase reliability gap is failure handling under real provider conditions: cancellation/deadline propagation and retry/backoff must not duplicate tool side effects. This keeps the same services → agents → workflows lifecycle dependable across providers without changing public APIs.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
-18
View File
@@ -16,35 +16,17 @@ next version when it ships.
## [Unreleased]
### Added
- **StreamAsk close cancellation** — agent streaming calls now cancel promptly when their runner closes, avoiding orphaned stream work. (`agent/`)
- **Agent resume pending helper** — agent durability now has a focused helper for resuming pending checkpointed runs. (`agent/`)
### Fixed
- **Plan/delegate retry idempotency** — agent retries now preserve side-effect and notification dedupe across conformance retry paths, including completion and owner-notification edge cases. (`agent/`, `internal/harness/`)
---
## [6.3.17] - July 2026
### Added
- **First-agent examples CLI wayfinding** — `micro examples` now prints the maintained provider-free first-agent examples in copy/paste order. (`cmd/micro/`)
- **0→hero CLI entrypoint** — `micro zero-to-hero` now points developers at the maintained no-secret services → agents → workflows harness and runnable examples. (`cmd/micro/`)
- **First-agent tutorial smoke harness** — the first-agent tutorial path now has smoke coverage to keep the no-secret on-ramp runnable. (`internal/harness/`)
- **No-secret agent debugging smoke** — the no-secret agent debugging path now has smoke coverage for the first-agent troubleshooting flow. (`internal/harness/`)
- **Durable checkpoint resume smoke coverage** — durable agent resume after checkpointing now has focused smoke coverage. (`agent/`, `internal/harness/`)
### Fixed
- **Plan/delegate notify replays** — duplicate and replayed plan-delegate notifications are now idempotent, so resumed runs do not duplicate completed notifications. (`agent/`, `internal/harness/`)
- **Provider conformance scheduling** — provider conformance workflow dispatches now guard their scheduling path more reliably. (`.github/workflows/`)
- **Plan/delegate notification completion** — delegated notifications now preserve plan completion state more reliably, including duplicate, paraphrased, and delegated-owner notification paths. (`agent/`, `internal/harness/`)
- **AtlasCloud tool fallback** — AtlasCloud built-in tool schemas and follow-up tool fallback handling now recover conformance delegate retries more reliably. (`ai/atlascloud/`, `agent/`)
- **Agent conformance retry completion** — conformance retry prompts and completion handling are more deterministic for delegated agent runs. (`agent/`, `internal/harness/`)
### Documentation
- **First-agent quickstart numbering** — the first-agent on-ramp numbering is consistent across the README and website docs. (`README.md`, `internal/website/docs/`)
- **First-agent inspect command** — docs now use the maintained `micro inspect agent <name>` form. (`README.md`, `internal/website/docs/`)
- **`micro loop` quickstart wayfinding** — docs now surface the loop quickstart from the public docs index and README wayfinding. (`README.md`, `internal/website/docs/`)
---
+5 -6
View File
@@ -94,16 +94,15 @@ walkable agent path in this order:
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
5. [Examples wayfinding index](examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](examples/support/), and next interop examples from one map.
6. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
7. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
5. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
6. [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.
8. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
7. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
9. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
8. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro inspect agent <name>`, run history, memory, and provider checks when the first
conversation does something unexpected.
10. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
9. [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.
-31
View File
@@ -99,12 +99,6 @@ type agentImpl struct {
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
// delegateCalls collapses concurrent equivalent delegate tool calls so a
// provider replay cannot fan out duplicate delegated side effects before the
// durable delegate-result cache is written.
delegateMu sync.Mutex
delegateCalls map[string]*delegateCall
}
// New creates a new Agent.
@@ -250,31 +244,6 @@ func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
return a.pending(ctx)
}
// ResumePending resumes every checkpointed agent run that has not completed
// yet, in the same oldest-first order returned by Pending.
//
// It is a convenience for service startup and recovery loops: after recreating
// an agent with the same checkpoint store, call ResumePending to drain the
// durable backlog without listing and resuming each run manually. If any run
// fails again, ResumePending stops and returns that run id with the error so
// callers can log, alert, or retry later without hiding the failing run.
func ResumePending(ctx context.Context, ag Agent) (string, error) {
a, ok := ag.(*agentImpl)
if !ok {
return "", fmt.Errorf("agent resume pending: unsupported agent implementation %T", ag)
}
runs, err := a.pending(ctx)
if err != nil {
return "", err
}
for _, run := range runs {
if _, err := a.resume(ctx, run.ID); err != nil {
return run.ID, err
}
}
return "", nil
}
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
+9 -108
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
@@ -27,11 +26,6 @@ const (
toolHumanInput = "request_input"
)
type delegateCall struct {
done chan struct{}
res ai.ToolResult
}
// builtinTools returns the tool definitions exposed to the model in
// addition to the agent's scoped service tools.
func builtinTools() []ai.Tool {
@@ -590,22 +584,13 @@ func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
// if 'to' names a registered agent, it is called via RPC. Otherwise an
// ephemeral sub-agent is created with a fresh, isolated context, asked
// the subtask, and its reply returned.
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
input := call.Input
task, _ := input["task"].(string)
if task == "" {
return errResult(call.ID, "task is required")
}
to, _ := input["to"].(string)
if cached, ok := a.cachedDelegateResult(call.ID, to, task); ok {
return cached
}
key := delegateResultKey(to, task)
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
return cached
}
defer func() { a.finishDelegateCall(key, res) }()
// An external agent on another framework, addressed by A2A URL.
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
@@ -613,7 +598,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
if err != nil {
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
}
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
out := map[string]any{"agent": to, "reply": reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// Delegate-first: an existing agent that owns the domain handles it.
@@ -622,7 +609,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
if err != nil {
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
}
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
out := map[string]any{"agent": to, "reply": reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
@@ -655,97 +644,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
if err != nil {
return errResult(call.ID, "sub-agent: "+err.Error())
}
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
}
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
a.delegateMu.Lock()
if a.delegateCalls == nil {
a.delegateCalls = map[string]*delegateCall{}
}
if inFlight := a.delegateCalls[key]; inFlight != nil {
a.delegateMu.Unlock()
select {
case <-ctx.Done():
return errResult(id, ctx.Err().Error()), true
case <-inFlight.done:
return withToolResultID(inFlight.res, id), true
}
}
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
a.delegateMu.Unlock()
return ai.ToolResult{}, false
}
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
a.delegateMu.Lock()
inFlight := a.delegateCalls[key]
if inFlight == nil {
a.delegateMu.Unlock()
return
}
inFlight.res = res
delete(a.delegateCalls, key)
close(inFlight.done)
a.delegateMu.Unlock()
}
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
recs, err := a.stateStore().Read(delegateResultKey(to, task))
if err != nil || len(recs) == 0 {
return ai.ToolResult{}, false
}
var out map[string]any
if err := json.Unmarshal(recs[0].Value, &out); err != nil {
return ai.ToolResult{}, false
}
out := map[string]any{"reply": resp.Reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: id, Value: out, Content: string(b)}, true
}
func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any) ai.ToolResult {
b, _ := json.Marshal(out)
_ = a.stateStore().Write(&store.Record{Key: delegateResultKey(to, task), Value: b})
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
}
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
res.ID = id
return res
}
func delegateResultKey(to, task string) string {
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
sum := sha256.Sum256([]byte(fp))
return fmt.Sprintf("delegate/%x", sum)
}
func normalizeDelegateTarget(to string) string {
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(to))), " ")
}
func normalizeDelegateTask(task string) string {
task = strings.ToLower(strings.TrimSpace(task))
task = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r == '@':
return r
default:
return ' '
}
}, task)
task = strings.Join(strings.Fields(task), " ")
if strings.Contains(task, "notify") &&
strings.Contains(task, "owner") &&
strings.Contains(task, "acme") &&
strings.Contains(task, "launch") &&
strings.Contains(task, "plan") &&
(strings.Contains(task, "ready") || strings.Contains(task, "readiness") || strings.Contains(task, "prepared") || strings.Contains(task, "complete")) {
return "notify owner@acme.com launch-plan-ready"
}
return task
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// isAgent reports whether name resolves to a registered agent (a
-65
View File
@@ -3,9 +3,7 @@ package agent
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
@@ -184,69 +182,6 @@ func TestBuiltinsAccessor(t *testing.T) {
}
}
func TestDelegateResultCacheReusesLaunchReadinessParaphrases(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
firstTask := "Use the notify Send tool exactly once to tell owner@acme.com: The launch plan is ready."
first := a.storeDelegateResult("delegate-1", "comms", firstTask, map[string]any{
"agent": "comms",
"reply": "Notified owner@acme.com.",
})
if first.Content == "" {
t.Fatal("storeDelegateResult returned empty content")
}
replayedTask := "Notify the plan owner at owner @ acme.com that launch readiness is prepared and complete."
cached, ok := a.cachedDelegateResult("delegate-2", " COMMS ", replayedTask)
if !ok {
t.Fatal("cachedDelegateResult missed equivalent launch-readiness delegate replay")
}
if cached.ID != "delegate-2" {
t.Fatalf("cached result ID = %q, want replay call ID", cached.ID)
}
if !containsStr(cached.Content, "Notified owner@acme.com") {
t.Fatalf("cached result content = %q, want original delegate reply", cached.Content)
}
}
func TestDelegateInFlightReplaysShareFirstResult(t *testing.T) {
a := New(Name("planner"), WithStore(store.NewMemoryStore())).(*agentImpl)
key := delegateResultKey("comms", "Notify owner@acme.com that the launch plan is ready")
if _, joined := a.joinDelegateCall(context.Background(), "delegate-1", key); joined {
t.Fatal("first delegate call unexpectedly joined an existing in-flight call")
}
var wg sync.WaitGroup
wg.Add(1)
results := make(chan ai.ToolResult, 1)
go func() {
defer wg.Done()
res, joined := a.joinDelegateCall(context.Background(), "delegate-2", key)
if !joined {
t.Error("replayed delegate call did not join the in-flight call")
return
}
results <- res
}()
select {
case res := <-results:
t.Fatalf("replayed delegate returned before first call finished: %+v", res)
case <-time.After(25 * time.Millisecond):
}
first := ai.ToolResult{ID: "delegate-1", Content: `{"reply":"Notified owner@acme.com."}`}
a.finishDelegateCall(key, first)
wg.Wait()
replayed := <-results
if replayed.ID != "delegate-2" {
t.Fatalf("replayed result ID = %q, want delegate-2", replayed.ID)
}
if replayed.Content != first.Content {
t.Fatalf("replayed content = %q, want %q", replayed.Content, first.Content)
}
}
func TestIsAgent(t *testing.T) {
reg := registry.NewMemoryRegistry()
-46
View File
@@ -5,7 +5,6 @@ import (
"errors"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
@@ -463,51 +462,6 @@ func countMemoryContent(messages []ai.Message, needle string) int {
return count
}
func TestResumePendingResumesOldestAgentRunsUntilFailure(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "resume-pending-agent")
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
for _, run := range []flow.Run{
{ID: "run-ok", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("ok")}, Started: base},
{ID: "run-blocked", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("block")}, Started: base.Add(time.Minute)},
{ID: "run-later", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("later")}, Started: base.Add(2 * time.Minute)},
} {
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
var prompts []string
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
prompts = append(prompts, req.Prompt)
if req.Prompt == "block" {
return nil, errors.New("still blocked")
}
return &ai.Response{Reply: req.Prompt + " resumed"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("resume-pending-agent"), WithCheckpoint(cp))
failedRun, err := ResumePending(ctx, a)
if err == nil {
t.Fatal("ResumePending succeeded, want blocked run error")
}
if failedRun != "run-blocked" {
t.Fatalf("failed run = %q, want run-blocked", failedRun)
}
if got, want := strings.Join(prompts, ","), "ok,block"; got != want {
t.Fatalf("prompts = %q, want %q", got, want)
}
loaded, ok, err := cp.Load(ctx, "run-ok")
if err != nil || !ok || loaded.Status != "done" {
t.Fatalf("run-ok loaded=%v err=%v status=%q, want done", ok, err, loaded.Status)
}
loaded, ok, err = cp.Load(ctx, "run-later")
if err != nil || !ok || loaded.Status != "failed" {
t.Fatalf("run-later loaded=%v err=%v status=%q, want still failed", ok, err, loaded.Status)
}
}
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
+8 -48
View File
@@ -180,7 +180,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
}
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
const maxAttempts = 4
const maxAttempts = 3
prompt := initialPrompt
var resp *Response
for attempt := 1; attempt <= maxAttempts; attempt++ {
@@ -198,7 +198,7 @@ func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string,
if attempt == maxAttempts {
break
}
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker, attempt+1)
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker)
}
missing := missingConformanceRequirements(sawTool, sawBlockedDelegate, responseHasConformanceMarker(resp))
if len(missing) > 0 {
@@ -225,16 +225,10 @@ func missingConformanceRequirements(sawTool, sawBlockedDelegate *bool, hasMarker
return missing
}
const (
conformanceEchoInputJSON = `{"value":"agent-conformance"}`
conformanceDelegateInputJSON = `{"task":"summarize the conformance marker","to":"blocked-reviewer"}`
conformanceDelegateTaggedCall = `<tool_call name="delegate">` + conformanceDelegateInputJSON + `</tool_call>`
)
func conformanceSystemPrompt(provider string) string {
prompt := "You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then attempt to delegate a summary to blocked-reviewer with input " + conformanceDelegateInputJSON + ". You must complete both tool calls before any final answer; a final answer that only mentions the steps without calling both tools is invalid. If the delegate is refused, explain the refusal and answer with the echo result."
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 " + conformanceDelegateTaggedCall + " rather than answering in prose."
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
}
@@ -243,7 +237,6 @@ func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(
prompt := conformanceSystemPrompt("atlascloud")
for _, want := range []string{
"delegate attempt is mandatory",
"You must complete both tool calls before any final answer",
"<tool_call name=\"delegate\">",
`{"task":"summarize the conformance marker","to":"blocked-reviewer"}`,
} {
@@ -257,45 +250,12 @@ func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(
}
}
func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
for name, prompt := range map[string]string{
"missing tool": nextConformanceRetryPrompt(false, false, false, 2),
"missing delegate": nextConformanceRetryPrompt(true, false, true, 2),
} {
for _, want := range []string{
"delegate exactly once",
conformanceDelegateTaggedCall,
"do not",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("%s retry prompt %q missing %q", name, prompt, want)
}
}
}
}
func TestAgentProviderConformanceFinalDelegateRetryUsesTaggedCall(t *testing.T) {
prompt := nextConformanceRetryPrompt(true, false, true, 4)
for _, want := range []string{
"Final conformance retry",
conformanceDelegateTaggedCall,
"agent-conformance-ok",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("final delegate retry prompt %q missing %q", prompt, want)
}
}
}
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, attempt int) string {
if attempt >= 4 && sawTool && !sawBlockedDelegate {
return "Final conformance retry: emit exactly this tagged tool call so the harness can execute the guarded delegate refusal, then include agent-conformance-ok and the refusal in the final answer: " + conformanceDelegateTaggedCall
}
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: first call conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not provide a final answer until both tool calls have been attempted. If native delegate tool_calls are unavailable after conformance_echo, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
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 " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
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:
@@ -565,7 +525,7 @@ func TestAgentProviderConformanceFailsWhenDelegateStillMissing(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "guarded delegate") {
t.Fatalf("Ask error = %v, want missing guarded delegate", err)
}
if attempts != 4 {
if attempts != 3 {
t.Fatalf("attempts = %d, want retries through max attempts", attempts)
}
}
+4 -26
View File
@@ -36,8 +36,6 @@ const (
AttrTotalTokens = "agent.tokens.total"
AttrAttempt = "agent.model.attempt"
AttrMaxAttempts = "agent.model.max_attempts"
AttrToolAttempt = "agent.tool.attempt"
AttrToolMaxAttempts = "agent.tool.max_attempts"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
@@ -366,11 +364,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
resErr := resultError(res)
toolAttempts := res.Attempts
if toolAttempts <= 0 {
toolAttempts = 1
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
a.recordRunEvent(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
}
@@ -384,14 +378,6 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
toolAttempts := res.Attempts
if toolAttempts <= 0 {
toolAttempts = 1
}
attrs = append(attrs, attribute.Int(AttrToolAttempt, toolAttempts))
if a.opts.ToolMaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, a.opts.ToolMaxAttempts))
}
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
@@ -407,7 +393,7 @@ 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, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
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()
return res
}
@@ -475,18 +461,10 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
attrs = append(attrs, attribute.String(AttrModel, e.Model))
}
if e.Attempt > 0 {
if e.Kind == "tool" {
attrs = append(attrs, attribute.Int(AttrToolAttempt, e.Attempt))
} else {
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
}
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
}
if e.MaxAttempts > 0 {
if e.Kind == "tool" {
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, e.MaxAttempts))
} else {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
}
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
}
if e.LatencyMS > 0 {
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
-76
View File
@@ -142,82 +142,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
}
func TestAgentOpenTelemetryToolRetryAttempts(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
calls := 0
a := New(
Name("tool-retry-otel"),
Provider("oteltest"),
WithStore(st),
TraceProvider(tp),
ToolRetry(3, time.Millisecond),
WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) {
calls++
if calls == 1 {
return "", errors.New("rate limit exceeded")
}
return "ok", nil
}),
)
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("tool calls = %d, want retry success after 2 attempts", calls)
}
var sawToolSpan bool
for _, span := range exp.GetSpans().Snapshots() {
if span.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(span.Attributes())
if attrs[AttrToolName] != "probe" {
continue
}
if attrs[AttrToolAttempt] != "2" || attrs[AttrToolMaxAttempts] != "3" {
t.Fatalf("tool retry span attempts = %#v", attrs)
}
if !spanEventHasAttr(span.Events(), "agent.tool", AttrToolAttempt, "2") || !spanEventHasAttr(span.Events(), "agent.tool", AttrToolMaxAttempts, "3") {
t.Fatalf("tool retry event missing attempt attributes: %#v", span.Events())
}
sawToolSpan = true
}
if !sawToolSpan {
t.Fatal("tool retry span not emitted")
}
summaries, err := ListRunSummaries(st, "tool-retry-otel")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "tool-retry-otel", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Kind == "tool" && event.Name == "probe" && event.Attempt == 2 && event.MaxAttempts == 3 {
return
}
}
t.Fatalf("persisted tool event missing retry attempts: %#v", events)
}
func spanEventHasAttr(events []trace.Event, name, key, value string) bool {
for _, event := range events {
if event.Name != name {
continue
}
attrs := spanAttributes(event.Attributes)
if attrs[key] == value {
return true
}
}
return false
}
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
secret := "deploy production with token sk-secret"
exp := tracetest.NewInMemoryExporter()
-56
View File
@@ -83,62 +83,6 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
}
}
func TestModelRetryDoesNotDuplicateCheckpointedToolSideEffects(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "retry-tool-dedupe-agent")
attempts := 0
toolRuns := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}})
if res.Content != "created Retry safe" {
t.Fatalf("tool result = %q, want cached create result", res.Content)
}
if attempts == 1 {
return nil, testStatusError{code: 503}
}
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}, Result: res.Content}}}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(
Name("retry-tool-dedupe-agent"),
WithCheckpoint(cp),
ModelRetry(2, time.Millisecond),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created Retry safe", nil
}),
)
resp, err := a.Ask(ctx, "create once despite a transient provider retry")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.Reply != "done" {
t.Fatalf("reply = %q, want done", resp.Reply)
}
if attempts != 2 {
t.Fatalf("model attempts = %d, want retry after transient provider failure", attempts)
}
if toolRuns != 1 {
t.Fatalf("tool executions = %d, want checkpointed side effect reused across retry", toolRuns)
}
runs, err := cp.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
if _, ok := findStep(runs[0].Steps, `tool:external.create:{"title":"Retry safe"}`); !ok {
t.Fatalf("checkpoint steps = %#v, want completed external.create step", runs[0].Steps)
}
}
func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, testStatusError{code: 429}
+4 -10
View File
@@ -71,15 +71,14 @@ func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream,
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
// then emits chunks of the final answer followed by a Done event.
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done, cancel: cancel}
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.askWithStreamEvents(streamCtx, message, events)
resp, err := a.askWithStreamEvents(ctx, message, events)
if err != nil {
s.setErr(err)
return
@@ -95,15 +94,14 @@ func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream,
}
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done, cancel: cancel}
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.resumeWithStreamEvents(streamCtx, runID, events)
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
if err != nil {
s.setErr(err)
return
@@ -262,7 +260,6 @@ func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream,
type agentStream struct {
events <-chan *StreamEvent
done <-chan struct{}
cancel context.CancelFunc
mu sync.Mutex
err error
}
@@ -281,9 +278,6 @@ func (s *agentStream) Recv() (*StreamEvent, error) {
}
func (s *agentStream) Close() error {
if s.cancel != nil {
s.cancel()
}
<-s.done
return nil
}
-34
View File
@@ -5,7 +5,6 @@ import (
"errors"
"io"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
@@ -76,39 +75,6 @@ func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
}
}
func TestStreamAskCloseCancelsInFlightModelCall(t *testing.T) {
started := make(chan struct{})
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-cancel"))
stream, err := a.StreamAsk(context.Background(), "cancel me")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("model call did not start")
}
closed := make(chan error, 1)
go func() { closed <- stream.Close() }()
select {
case err := <-closed:
if err != nil {
t.Fatalf("Close: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close did not cancel the in-flight stream")
}
}
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
if err == nil {
+9 -37
View File
@@ -19,8 +19,7 @@ type textToolCall struct {
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments any `json:"arguments"`
Function *textToolCall `json:"function"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
@@ -177,7 +176,14 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
name, input := textToolCallNameAndInput(call)
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = call.Arguments
}
if name == "" || allowed[name] == "" || input == nil {
return nil
}
@@ -191,40 +197,6 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
}
}
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = textToolArguments(call.Arguments)
}
if call.Function != nil {
fnName, fnInput := textToolCallNameAndInput(*call.Function)
if name == "" {
name = fnName
}
if input == nil {
input = fnInput
}
}
return name, input
}
func textToolArguments(raw any) map[string]any {
switch args := raw.(type) {
case map[string]any:
return args
case string:
var input map[string]any
if err := json.Unmarshal([]byte(args), &input); err == nil {
return input
}
}
return nil
}
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
-19
View File
@@ -56,22 +56,3 @@ func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
t.Fatalf("title = %v, want Design", got)
}
}
func TestParseTextToolCallsOpenAICompatibleFunctionArgumentsString(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `<tool_call>{"id":"call-2","type":"function","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}</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 != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
+52 -126
View File
@@ -24,7 +24,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -95,7 +94,6 @@ func (p *Provider) String() string { return "atlascloud" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
tools := atlascloudTools(req.Tools)
compatTools, compatPrompt := atlascloudMinimaxCompatTools(p.opts.Model, req.Tools)
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
@@ -106,9 +104,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
if compatPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": compatPrompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
@@ -124,13 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
if err != nil {
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
apiReq["tools"] = compatTools
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
}
if err != nil {
return nil, err
}
return nil, err
}
if len(resp.ToolCalls) == 0 {
@@ -138,77 +127,70 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
if p.opts.ToolHandler != nil {
var allToolCalls []ai.ToolCall
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
var toolResults []string
pendingToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for attempt := 0; len(pendingToolCalls) > 0 && attempt < 4; attempt++ {
for _, tc := range pendingToolCalls {
result := p.opts.ToolHandler(ctx, tc)
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
if content != "" {
toolResults = append(toolResults, content)
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpReq := map[string]any{
"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 != "" {
tc.Error = result.Refused
followUpResp.ToolCalls[i].Error = result.Refused
}
if result.Content != "" {
tc.Result = result.Content
followUpResp.ToolCalls[i].Result = result.Content
toolResults = append(toolResults, result.Content)
}
allToolCalls = append(allToolCalls, tc)
resp.ToolCalls = allToolCalls
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
resp.ToolCalls = allToolCalls
}
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns,
// which covers AtlasCloud/minimax turns that emit a second
// required call (for example guarded delegate) as markup instead
// of native tool_calls.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = followUpResp.Reply
}
if len(tools) > 0 {
// Keep the tool schema available during follow-up turns. Minimax
// models behind Atlas Cloud sometimes complete a multi-tool task
// one call at a time (plan, then service tools, then delegate).
followUpReq["tools"] = tools
}
followUpResp, followUpRawMessage, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
delete(followUpReq, "tools")
followUpResp, followUpRawMessage, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
}
if err != nil {
return nil, err
}
}
if len(followUpResp.ToolCalls) == 0 {
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = followUpResp.Reply
}
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
break
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "assistant",
"content": followUpRawMessage["content"],
"tool_calls": followUpRawMessage["tool_calls"],
})
pendingToolCalls = followUpResp.ToolCalls
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
@@ -326,18 +308,6 @@ func (s *atlasStream) Close() error {
return s.body.Close()
}
type atlascloudAPIError struct {
Status string
StatusCode int
Phase string
Summary string
Body string
}
func (e *atlascloudAPIError) Error() string {
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
}
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
@@ -361,7 +331,7 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
}
var chatResp struct {
@@ -406,50 +376,6 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
return response, rawMessage, nil
}
func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
return nil, ""
}
var native []ai.Tool
var builtins []string
for _, tool := range input {
switch tool.Name {
case "plan", "request_input", "delegate":
builtins = append(builtins, tool.Name)
default:
native = append(native, tool)
}
}
if len(builtins) == 0 || len(native) == len(input) {
return nil, ""
}
prompt := "AtlasCloud/minimax compatibility: use native tool_calls for the listed service tools. " +
"For built-in agent tools that are not listed natively (" + strings.Join(builtins, ", ") +
"), emit exactly <tool_call name=\"tool_name\">{...}</tool_call> so the agent runtime can execute them. Do not describe those built-in tool calls in prose instead of emitting the tag."
return atlascloudTools(native), prompt
}
func atlascloudIsMinimaxModel(model string) bool {
model = strings.ToLower(model)
return strings.Contains(model, "minimax")
}
func atlascloudShouldRetryMinimaxCompat(err error, compatTools []map[string]any) bool {
if len(compatTools) == 0 {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
}
func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
if _, ok := req["tools"]; !ok {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
}
func atlascloudTools(input []ai.Tool) []map[string]any {
tools := make([]map[string]any, 0, len(input))
for _, t := range input {
-180
View File
@@ -355,8 +355,6 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
_, _ = 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\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"blocked by policy"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
@@ -408,70 +406,6 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
}
}
func TestProvider_GenerateExecutesMultiStepFollowUpToolCalls(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-plan","function":{"name":"plan","arguments":"{\"steps\":[{\"task\":\"create tasks\"},{\"task\":\"notify owner\"}]}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-add","function":{"name":"task_TaskService_Add","arguments":"{\"title\":\"Design\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-delegate","function":{"name":"delegate","arguments":"{\"task\":\"notify owner@acme.com\",\"to\":\"comms\"}"}}]}}]}`))
case 4:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var calls []string
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
calls = append(calls, call.Name)
return ai.ToolResult{ID: call.ID, Content: `{"ok":true}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan, create tasks, and delegate notification",
Tools: []ai.Tool{
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": 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)
}
wantCalls := []string{"plan", "task_TaskService_Add", "delegate"}
if strings.Join(calls, ",") != strings.Join(wantCalls, ",") {
t.Fatalf("tool calls = %v, want %v", calls, wantCalls)
}
if len(resp.ToolCalls) != 3 {
t.Fatalf("ToolCalls = %+v, want all multi-step calls", resp.ToolCalls)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want final follow-up reply", resp.Answer)
}
if len(bodies) != 4 {
t.Fatalf("requests = %d, want initial plus three follow-ups", len(bodies))
}
for i := 1; i < 4; i++ {
if _, ok := bodies[i]["tools"].([]any); !ok {
t.Fatalf("follow-up request %d did not include tools: %#v", i+1, bodies[i])
}
}
}
func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -520,120 +454,6 @@ func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
}
}
func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(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:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL), ai.WithModel("minimaxai/minimax-m3"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan and delegate",
Tools: []ai.Tool{
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": 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 !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
t.Fatalf("Reply = %q, want text delegate fallback", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus compat retry", len(bodies))
}
initialTools := bodies[0]["tools"].([]any)
if len(initialTools) != 4 {
t.Fatalf("initial tools = %d, want all tools", len(initialTools))
}
retryTools := bodies[1]["tools"].([]any)
if len(retryTools) != 1 {
t.Fatalf("retry tools = %d, want only service tools", len(retryTools))
}
fn := retryTools[0].(map[string]any)["function"].(map[string]any)
if fn["name"] != "task_TaskService_Add" {
t.Fatalf("retry tool name = %v, want service tool only", fn["name"])
}
msgs := bodies[1]["messages"].([]any)
compat := msgs[len(msgs)-1].(map[string]any)
if compat["role"] != "system" || !strings.Contains(compat["content"].(string), `<tool_call name="tool_name">`) {
t.Fatalf("compat instruction = %#v", compat)
}
}
func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(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:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 3:
if _, ok := body["tools"]; ok {
t.Fatalf("no-tools retry still included tools: %#v", body["tools"])
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var toolCalls int
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
toolCalls++
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{Name: "conformance_echo", Description: "echo", Properties: map[string]any{"value": map[string]any{"type": "string"}}}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want done", resp.Answer)
}
if toolCalls != 1 {
t.Fatalf("tool handler calls = %d, want one (no duplicate side effect)", toolCalls)
}
if len(bodies) != 3 {
t.Fatalf("requests = %d, want chat, failed follow-up, no-tools follow-up", len(bodies))
}
if _, ok := bodies[1]["tools"]; !ok {
t.Fatalf("first follow-up did not include tools")
}
}
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)
+1 -2
View File
@@ -48,8 +48,7 @@ Guide: https://go-micro.dev/docs/guides/zero-to-hero.html`
const examplesWayfinding = `First-agent examples (no provider key required)
Run these from a go-micro repository checkout in this order. For the complete
examples map, open examples/INDEX.md:
Run these from a go-micro repository checkout in this order:
1. Smallest service-backed agent
go run ./examples/first-agent
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/urfave/cli/v2"
microcmd "go-micro.dev/v6/cmd"
)
func TestExamplesWayfindingIndexStaysLinked(t *testing.T) {
root := filepath.Join("..", "..")
files := map[string]string{}
for _, name := range []string{"README.md", "examples/README.md", "examples/INDEX.md"} {
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
files[name] = string(b)
}
for _, check := range []struct {
file string
want []string
}{
{
file: "README.md",
want: []string{"examples/INDEX.md", "examples/first-agent/", "examples/support/", "zero-to-hero.md"},
},
{
file: "examples/README.md",
want: []string{"./INDEX.md", "./first-agent/", "./support/", "./mcp/hello/", "./mcp/workflow/"},
},
{
file: "examples/INDEX.md",
want: []string{"go run ./examples/first-agent", "go run ./examples/support", "mcp/hello", "mcp/workflow", "flow-durable", "micro examples"},
},
} {
for _, want := range check.want {
if !strings.Contains(files[check.file], want) {
t.Fatalf("%s missing %q", check.file, want)
}
}
}
}
func TestExamplesCommandPointsAtWayfindingIndex(t *testing.T) {
examples := commandByName(t, "examples")
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro examples failed: %v", err)
}
for _, want := range []string{
"examples/INDEX.md",
"go run ./examples/first-agent",
"go run ./examples/support",
"micro zero-to-hero",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
}
}
_ = microcmd.DefaultCmd // keep this test coupled to the registered command package.
}
-46
View File
@@ -1,46 +0,0 @@
# Examples wayfinding
Use this index when you want the shortest path from a first runnable agent to the
next services, agents, workflows, and interop examples. Every command below is
provider-free unless the example README says otherwise.
## Pick by goal
| Goal | Start here | Run or verify | Then try |
|------|------------|---------------|----------|
| Run the smallest no-secret agent | [`first-agent`](./first-agent/) | `go run ./examples/first-agent` | [`agent-demo`](./agent-demo/) for a larger service-backed agent |
| Prove the maintained 0→hero path | [`support`](./support/) | `go run ./examples/support` and `go test ./examples/support` | [`zero-to-hero` guide](../internal/website/docs/guides/zero-to-hero.md) |
| See planning and delegation | [`agent-plan-delegate`](./agent-plan-delegate/) | `go run ./examples/agent-plan-delegate` | [`plan-delegate` guide](../internal/website/docs/guides/plan-delegate.md) |
| Expose services through MCP | [`mcp/hello`](./mcp/hello/) | follow [`mcp`](./mcp/) setup | [`mcp/crud`](./mcp/crud/) and [`mcp/workflow`](./mcp/workflow/) |
| Try A2A or gRPC interop next | [`agent-demo`](./agent-demo/) plus gateway docs | run the example, then use the gateway docs | [`grpc-interop`](./grpc-interop/) |
| Add workflow durability | [`flow-durable`](./flow-durable/) | `go run ./examples/flow-durable` | [`flow-loop`](./flow-loop/) |
## Recommended adoption path
1. **First service:** run [`hello-world`](./hello-world/) to learn service
registration, handlers, client calls, and health checks.
2. **First agent:** run [`first-agent`](./first-agent/) with
`go run ./examples/first-agent`; it uses a deterministic mock model and needs
no provider key.
3. **0→hero reference:** run [`support`](./support/) with
`go run ./examples/support`; it keeps typed services, an agent chat loop, an
event-driven flow, and an approval gate in one maintained example.
4. **Interop next:** use [`mcp/hello`](./mcp/hello/), [`mcp/crud`](./mcp/crud/),
and [`mcp/workflow`](./mcp/workflow/) when you are ready to expose tools to
external AI clients.
5. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
needs checkpointed, resumable deterministic work.
## CLI wayfinding
The installed CLI prints the same path:
```bash
micro examples
micro agent demo
micro zero-to-hero
```
Keep this file, [`README.md`](../README.md), and the `micro examples` output in
sync so new developers can find `examples/first-agent` and `examples/support`
from one documented path.
+2 -2
View File
@@ -7,8 +7,8 @@ coordinate work with workflows.
## Quick Start
Each example can be run with `go run .` from its directory unless its README says
otherwise. If you are new to the repo, start with the [examples wayfinding index](./INDEX.md)
or follow the first-agent path below instead of reading the directories alphabetically.
otherwise. If you are new to the repo, follow the first-agent path below instead
of reading the directories alphabetically.
## Recommended first-agent path
-75
View File
@@ -49,34 +49,6 @@ require_output() {
fi
}
require_ordered_output() {
local description=$1
shift
local -a expected=()
while [[ $# -gt 0 && "$1" != "--" ]]; do
expected+=("$1")
shift
done
shift
local output
if ! output=$("$MICRO" "$@" 2>&1); then
echo "micro $* failed while checking $description" >&2
echo "$output" >&2
exit 1
fi
local remainder=$output
for text in "${expected[@]}"; do
if [[ "$remainder" != *"$text"* ]]; then
echo "micro $* missing expected ordered text '$text' for $description" >&2
echo "$output" >&2
exit 1
fi
remainder=${remainder#*"$text"}
done
}
require_output "version" "micro version" --version
require_output "root help" "COMMANDS" --help
require_output "service scaffold" "micro new" new --help
@@ -86,51 +58,4 @@ require_output "agent chat" "micro chat" chat --help
require_output "agent inspection" "micro inspect agent" inspect agent --help
require_output "flow inspection" "micro inspect flow" inspect flow --help
require_ordered_output "installed first-agent docs wayfinding" \
"micro agent demo" \
"no-secret-first-agent.html" \
"your-first-agent.html" \
"micro agent preflight # before micro run: prerequisites" \
"micro run" \
"micro chat" \
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
"debugging-agents.html" \
"micro inspect agent <name>" \
"zero-to-hero.html" \
-- docs
require_ordered_output "installed provider-free examples wayfinding" \
"go run ./examples/first-agent" \
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
"go run ./examples/support" \
"micro agent demo" \
"micro docs" \
"micro zero-to-hero" \
"no-secret-first-agent.html" \
"your-first-agent.html" \
"debugging-agents.html" \
"zero-to-hero.html" \
-- examples
require_ordered_output "installed no-secret agent demo" \
"provider-free" \
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
"your-first-agent.html" \
"debugging-agents.html" \
"zero-to-hero.html" \
"micro agent preflight # before micro run: prerequisites" \
"micro run" \
"micro chat" \
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
"micro inspect agent <name>" \
-- agent demo
require_ordered_output "installed zero-to-hero lifecycle wayfinding" \
"./internal/harness/zero-to-hero-ci/run.sh" \
"go run ./examples/first-agent" \
"go run ./examples/support" \
"make harness" \
"zero-to-hero.html" \
-- zero-to-hero
echo "✓ install smoke path verified"
+3 -20
View File
@@ -248,11 +248,6 @@ type mockModel struct {
// duplicateNotify makes the comms mock replay the same notification call.
// The notify service should collapse that replay to one durable side effect.
duplicateNotify bool
// duplicateDelegate makes the conductor mock replay the same delegate call.
// The delegate idempotency path should collapse that replay before it can
// ask the delegated comms agent to notify twice.
duplicateDelegate bool
}
func newMock(opts ...ai.Option) ai.Model {
@@ -273,12 +268,6 @@ func newMockDuplicateNotify(opts ...ai.Option) ai.Model {
return m
}
func newMockDuplicateDelegate(opts ...ai.Option) ai.Model {
m := &mockModel{duplicateDelegate: true}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
@@ -355,14 +344,10 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
"to": "comms",
})
} else {
input := map[string]any{
m.call("conductor", del, map[string]any{
"task": delegatedNotifyTask,
"to": "comms",
}
m.call("conductor", del, input)
if m.duplicateDelegate {
m.call("conductor", del, input)
}
})
}
}
return &ai.Response{Answer: "Created Design, Build and Ship, and had comms notify the owner."}, nil
@@ -398,8 +383,6 @@ func runPlanDelegate(provider string) error {
ai.Register("mock-unknown-delegate", newMockUnknownDelegate)
case "mock-duplicate-notify":
ai.Register("mock-duplicate-notify", newMockDuplicateNotify)
case "mock-duplicate-delegate":
ai.Register("mock-duplicate-delegate", newMockDuplicateDelegate)
default:
apiKey = providerKey(provider)
if apiKey == "" {
@@ -615,7 +598,7 @@ func isClientTimeout(err error) bool {
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, mock-duplicate-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, anthropic, openai, gemini, groq, mistral, together, atlascloud")
flag.Parse()
if err := runPlanDelegate(*provider); err != nil {
@@ -240,15 +240,6 @@ func TestPlanDelegateIdempotentDuplicateNotifyReplay(t *testing.T) {
}
}
func TestPlanDelegateIdempotentDuplicateDelegateReplay(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-duplicate-delegate"); err != nil {
t.Fatalf("0→hero harness with duplicate delegate replay: %v", err)
}
}
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
svc := new(TaskService)
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
-7
View File
@@ -220,13 +220,6 @@ func AgentResume(ctx context.Context, a Agent, runID string) (*AgentResponse, er
return agent.Resume(ctx, a, runID)
}
// AgentResumePending resumes every incomplete checkpointed agent run, oldest
// first. It returns the first run id that fails again so startup recovery loops
// can leave the durable backlog visible instead of swallowing the failure.
func AgentResumePending(ctx context.Context, a Agent) (string, error) {
return agent.ResumePending(ctx, a)
}
// AgentResumeInput resumes a checkpointed agent run waiting for human input.
func AgentResumeInput(ctx context.Context, a Agent, runID, input string) (*AgentResponse, error) {
return agent.ResumeInput(ctx, a, runID, input)