Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bca3ec2e1d | |||
| 76fea3f01c | |||
| a5f8ff518d | |||
| cf9c015cf8 | |||
| 7248270f28 | |||
| 243d7904e8 | |||
| 364fb33cc8 | |||
| 7697b2587c | |||
| fa36114d73 | |||
| 5c5186a230 | |||
| 58a76bc32f | |||
| 2c562c8827 | |||
| ff1c6173cd | |||
| 2c7b2dbe51 | |||
| c51d53f5ec | |||
| 6a25bbfc05 | |||
| ff1bf69252 | |||
| 92853d353e | |||
| 391dc1cf05 | |||
| ae69583f34 | |||
| e2729b2c32 | |||
| ef46bfc37f | |||
| 81d61f93d0 | |||
| 5dc2a32233 | |||
| d8562edcf6 | |||
| 5a85cba982 | |||
| 019420c123 | |||
| 5b32014de1 | |||
| 32e522a2a9 | |||
| 811d617b46 | |||
| 32d0c46676 | |||
| 34e7cf1f5f | |||
| 091fb1d4df | |||
| 1c56d77c86 | |||
| 6f220089d5 | |||
| 58249e4a2f | |||
| e51cbfba01 | |||
| e68d0e0018 | |||
| e96898d362 | |||
| c88a090d10 | |||
| f6951d2bb0 | |||
| 94286204ad |
@@ -21,9 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Enforce exact-once AtlasCloud plan-delegate notifications** ([#4189](https://github.com/micro/go-micro/issues/4189)) — #4195 closed the guarded-delegate conformance gap from #4183, so the remaining live-provider Now-phase failure is the exact-once side-effect seam: AtlasCloud/minimax can complete the plan/delegate path but replay the delegated owner notification. Make the harness and idempotency path reject duplicate delegate/notify effects so services → agents → workflows behavior is reliable under real model retries.
|
||||
2. **Verify installed first-agent on-ramp commands** ([#4197](https://github.com/micro/go-micro/issues/4197)) — developer adoption is the current goal, and the canonical story in the README, website, and recent blog posts depends on a provider-free path that works from the installed CLI. Keep `micro agent demo`, `micro examples`, `micro zero-to-hero`, first-agent docs, debugging, and 0→hero wayfinding aligned and CI-verifiable so 0→1 does not regress while hardening continues.
|
||||
3. **Propagate cancellation and retry signals through provider model calls** ([#4175](https://github.com/micro/go-micro/issues/4175)) — after the exact-once live conformance gap and the installed on-ramp contract, 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.
|
||||
1. **Fix AtlasCloud A2A streaming fallback tool-call reporting** ([#4297](https://github.com/micro/go-micro/issues/4297)) — The guarded-delegation failure closed with #4307, so the top remaining Now-phase provider defect is the A2A streaming fallback path reporting neither tool-call evidence nor run metadata (`tool=false runInfo=false`). A2A streaming is a critical interop seam for agents-as-services, and the fix should stay narrowly focused on surfacing tool/run evidence in the existing harness.
|
||||
2. **Propagate cancellation and retry signals through provider model calls** ([#4273](https://github.com/micro/go-micro/issues/4273)) — Once the current live AtlasCloud reporting failure is stable, the remaining Now-phase resilience seam is provider-boundary cancellation/deadline/retry behavior across the agent loop. Keep it focused on fakes/tests and avoid public API or architecture changes.
|
||||
3. **CI-verify zero-to-hero deploy dry-run path** ([#4309](https://github.com/micro/go-micro/issues/4309)) — Developer adoption remains the current goal, and the docs now have a strong no-secret first-agent path; the next adoption gap is making sure the documented scaffold → run → chat → inspect → deploy dry-run lifecycle is continuously verified through the deploy boundary, not just described. This keeps the 0→hero contract honest without displacing the two active Now-phase provider/resilience fixes.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -16,17 +16,35 @@ 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/`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
|
||||
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
|
||||
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
|
||||
|
||||
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
.PHONY: test test-race test-coverage harness docs-wayfinding install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -19,6 +19,7 @@ help:
|
||||
@echo " make test-coverage - Run tests with coverage"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
|
||||
@echo " make docs-wayfinding - Verify first-agent docs wayfinding links resolve locally"
|
||||
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
|
||||
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
|
||||
@echo " make provider-conformance - Run harnesses against configured live providers"
|
||||
@@ -49,12 +50,19 @@ test-coverage:
|
||||
# This mirrors the default CI path so local dogfooding catches scaffold,
|
||||
# run/chat/inspect, and 0→hero regressions before a PR is opened.
|
||||
harness:
|
||||
$(MAKE) docs-wayfinding
|
||||
$(MAKE) install-smoke
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
go run ./internal/harness/agent-flow
|
||||
$(MAKE) provider-conformance-mock
|
||||
|
||||
# Verify the README and website first-agent/0→hero wayfinding links resolve to
|
||||
# maintained local docs and examples. This is a focused no-network guard for the
|
||||
# developer-adoption on-ramp.
|
||||
docs-wayfinding:
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestFirstAgentWayfindingDocs|TestFirstAgentWayfindingLinkTargetsResolve' -count=1
|
||||
|
||||
# Verify the documented install script and first-run CLI command boundaries without
|
||||
# provider keys or network access.
|
||||
install-smoke:
|
||||
@@ -110,4 +118,3 @@ gorelease-dry-run:
|
||||
-w /$(NAME) \
|
||||
$(GORELEASER_DOCKER_IMAGE) \
|
||||
--clean --verbose --skip=publish,validate --snapshot
|
||||
|
||||
|
||||
@@ -94,15 +94,16 @@ 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. [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
|
||||
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
|
||||
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
|
||||
7. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
8. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
service-backed agent and talk to it with `micro chat`.
|
||||
8. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
|
||||
9. [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.
|
||||
9. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
|
||||
10. [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.
|
||||
|
||||
|
||||
@@ -99,6 +99,12 @@ 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.
|
||||
@@ -244,6 +250,31 @@ 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()
|
||||
|
||||
+108
-9
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -26,6 +27,11 @@ 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 {
|
||||
@@ -584,13 +590,22 @@ 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) ai.ToolResult {
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res 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://") {
|
||||
@@ -598,9 +613,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
|
||||
}
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
}
|
||||
|
||||
// Delegate-first: an existing agent that owns the domain handles it.
|
||||
@@ -609,9 +622,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
|
||||
}
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
}
|
||||
|
||||
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
|
||||
@@ -644,9 +655,97 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "sub-agent: "+err.Error())
|
||||
}
|
||||
out := map[string]any{"reply": resp.Reply}
|
||||
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
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
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
|
||||
}
|
||||
|
||||
// isAgent reports whether name resolves to a registered agent (a
|
||||
|
||||
@@ -3,7 +3,9 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -182,6 +184,69 @@ 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()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
@@ -462,6 +463,51 @@ 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")
|
||||
|
||||
@@ -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 = 3
|
||||
const maxAttempts = 4
|
||||
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)
|
||||
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker, attempt+1)
|
||||
}
|
||||
missing := missingConformanceRequirements(sawTool, sawBlockedDelegate, responseHasConformanceMarker(resp))
|
||||
if len(missing) > 0 {
|
||||
@@ -259,8 +259,8 @@ func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(
|
||||
|
||||
func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
|
||||
for name, prompt := range map[string]string{
|
||||
"missing tool": nextConformanceRetryPrompt(false, false, false),
|
||||
"missing delegate": nextConformanceRetryPrompt(true, false, true),
|
||||
"missing tool": nextConformanceRetryPrompt(false, false, false, 2),
|
||||
"missing delegate": nextConformanceRetryPrompt(true, false, true, 2),
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"delegate exactly once",
|
||||
@@ -274,7 +274,23 @@ func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool) string {
|
||||
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
|
||||
}
|
||||
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."
|
||||
@@ -549,7 +565,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 != 3 {
|
||||
if attempts != 4 {
|
||||
t.Fatalf("attempts = %d, want retries through max attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -36,6 +36,8 @@ 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"
|
||||
@@ -364,7 +366,11 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
resErr := resultError(res)
|
||||
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)})
|
||||
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)})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -378,6 +384,14 @@ 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))
|
||||
}
|
||||
@@ -393,7 +407,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, 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, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
span.End()
|
||||
return res
|
||||
}
|
||||
@@ -461,10 +475,18 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
attrs = append(attrs, attribute.String(AttrModel, e.Model))
|
||||
}
|
||||
if e.Attempt > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolAttempt, e.Attempt))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
}
|
||||
}
|
||||
if e.MaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, e.MaxAttempts))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
}
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
|
||||
|
||||
@@ -142,6 +142,82 @@ 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()
|
||||
|
||||
@@ -83,6 +83,62 @@ 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}
|
||||
|
||||
+10
-4
@@ -71,14 +71,15 @@ 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}
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.askWithStreamEvents(ctx, message, events)
|
||||
resp, err := a.askWithStreamEvents(streamCtx, message, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -94,14 +95,15 @@ 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}
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
|
||||
resp, err := a.resumeWithStreamEvents(streamCtx, runID, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -260,6 +262,7 @@ 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
|
||||
}
|
||||
@@ -278,6 +281,9 @@ func (s *agentStream) Recv() (*StreamEvent, error) {
|
||||
}
|
||||
|
||||
func (s *agentStream) Close() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
<-s.done
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
@@ -75,6 +76,39 @@ 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 {
|
||||
|
||||
+165
-18
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -12,14 +13,16 @@ import (
|
||||
|
||||
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
|
||||
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
|
||||
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>(.*?)</[^<>]*>`)
|
||||
var taggedToolNameAttr = regexp.MustCompile(`(?i)(?:function|name|tool)\s*=\s*["\']?([^"\'\s>]+)`)
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input map[string]any `json:"input"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
Arguments any `json:"arguments"`
|
||||
Function *textToolCall `json:"function"`
|
||||
}
|
||||
|
||||
// executeTextToolCalls is a compatibility fallback for providers that return a
|
||||
@@ -90,6 +93,7 @@ func textToolCallKey(call ai.ToolCall) string {
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
text = html.UnescapeString(text)
|
||||
allowed := textToolNames(tools)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
@@ -98,6 +102,9 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
if calls := decodeFunctionTextToolCalls(text, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
for _, candidate := range jsonCandidates(text) {
|
||||
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
@@ -176,14 +183,7 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
|
||||
return collectTextToolCalls(nested, allowed)
|
||||
}
|
||||
call := mapToTextToolCall(x)
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = call.Arguments
|
||||
}
|
||||
name, input := textToolCallNameAndInput(call)
|
||||
if name == "" || allowed[name] == "" || input == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -197,6 +197,40 @@ 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) {
|
||||
@@ -230,17 +264,130 @@ func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.Tool
|
||||
}
|
||||
|
||||
func taggedToolName(tag string) string {
|
||||
for _, marker := range []string{"function=", "name=", "tool="} {
|
||||
if idx := strings.Index(tag, marker); idx >= 0 {
|
||||
name := strings.TrimSpace(tag[idx+len(marker):])
|
||||
name = strings.Trim(name, `"'`)
|
||||
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
|
||||
name = name[:end]
|
||||
match := taggedToolNameAttr.FindStringSubmatch(tag)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(match[1], `"'`)
|
||||
}
|
||||
|
||||
func decodeFunctionTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
|
||||
var out []ai.ToolCall
|
||||
for alias, canonical := range allowed {
|
||||
for _, body := range functionCallBodies(text, alias) {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
|
||||
continue
|
||||
}
|
||||
return strings.Trim(name, `"'`)
|
||||
out = append(out, ai.ToolCall{
|
||||
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
|
||||
Name: canonical,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
func functionCallBodies(text, name string) []string {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var bodies []string
|
||||
for searchFrom := 0; searchFrom < len(text); {
|
||||
idx := strings.Index(text[searchFrom:], name)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
start := searchFrom + idx
|
||||
open := start + len(name)
|
||||
if !isFunctionCallBoundary(text, start, open) {
|
||||
searchFrom = start + len(name)
|
||||
continue
|
||||
}
|
||||
bodyStart := open + 1
|
||||
bodyEnd, ok := balancedJSONObjectEnd(text, bodyStart)
|
||||
if !ok {
|
||||
searchFrom = bodyStart
|
||||
continue
|
||||
}
|
||||
bodies = append(bodies, strings.TrimSpace(text[bodyStart:bodyEnd]))
|
||||
searchFrom = bodyEnd + 1
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func isFunctionCallBoundary(text string, start, open int) bool {
|
||||
if open >= len(text) || text[open] != '(' {
|
||||
return false
|
||||
}
|
||||
if start > 0 {
|
||||
prev := text[start-1]
|
||||
if prev == '_' || prev == '.' || prev == '-' || prev == '$' || ('0' <= prev && prev <= '9') || ('A' <= prev && prev <= 'Z') || ('a' <= prev && prev <= 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := open + 1; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
continue
|
||||
case '{':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func balancedJSONObjectEnd(text string, start int) (int, bool) {
|
||||
for start < len(text) {
|
||||
switch text[start] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
start++
|
||||
case '{':
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := start; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if c == '\\' {
|
||||
escaped = true
|
||||
} else if c == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inString = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
for j := i + 1; j < len(text); j++ {
|
||||
switch text[j] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
continue
|
||||
case ')':
|
||||
return i + 1, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
|
||||
@@ -56,3 +56,93 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsTaggedMarkupWithSpacedNameAttribute(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call name = "delegate">{"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["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsHTMLEscapedTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call name="delegate">{"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionCallSyntax(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `I will now call delegate({"task":"summarize the conformance marker","to":"blocked-reviewer"}) before answering.`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionCallSyntaxHandlesNestedJSON(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `delegate({
|
||||
"task":"summarize the {escaped} marker",
|
||||
"meta":{"note":"paren ) and brace } in string"},
|
||||
"to":"blocked-reviewer"
|
||||
})`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the {escaped} marker" {
|
||||
t.Fatalf("task = %v, want nested JSON-safe task", got)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
|
||||
+126
-52
@@ -24,6 +24,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -94,6 +95,7 @@ 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},
|
||||
@@ -104,6 +106,9 @@ 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,
|
||||
@@ -119,7 +124,13 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
|
||||
apiReq["tools"] = compatTools
|
||||
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
@@ -127,70 +138,77 @@ 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 allToolCalls []ai.ToolCall
|
||||
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 _, 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])
|
||||
for attempt := 0; len(pendingToolCalls) > 0 && attempt < 4; attempt++ {
|
||||
for _, tc := range pendingToolCalls {
|
||||
result := p.opts.ToolHandler(ctx, tc)
|
||||
if result.Refused != "" {
|
||||
followUpResp.ToolCalls[i].Error = result.Refused
|
||||
tc.Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
followUpResp.ToolCalls[i].Result = result.Content
|
||||
tc.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,
|
||||
})
|
||||
}
|
||||
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
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
} else if len(toolResults) > 0 {
|
||||
resp.Answer = strings.Join(toolResults, "\n")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +326,18 @@ 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 {
|
||||
@@ -331,7 +361,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, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
|
||||
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
@@ -376,6 +406,50 @@ 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 {
|
||||
|
||||
@@ -355,6 +355,8 @@ 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))
|
||||
}
|
||||
@@ -406,6 +408,70 @@ 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) {
|
||||
@@ -454,6 +520,120 @@ 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)
|
||||
|
||||
@@ -48,7 +48,8 @@ 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:
|
||||
Run these from a go-micro repository checkout in this order. For the complete
|
||||
examples map, open examples/INDEX.md:
|
||||
|
||||
1. Smallest service-backed agent
|
||||
go run ./examples/first-agent
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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.
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
@@ -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, follow the first-agent path below instead
|
||||
of reading the directories alphabetically.
|
||||
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.
|
||||
|
||||
## Recommended first-agent path
|
||||
|
||||
|
||||
@@ -49,6 +49,34 @@ 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
|
||||
@@ -58,4 +86,51 @@ 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"
|
||||
|
||||
@@ -248,6 +248,11 @@ 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 {
|
||||
@@ -268,6 +273,12 @@ 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)
|
||||
@@ -344,10 +355,14 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
"to": "comms",
|
||||
})
|
||||
} else {
|
||||
m.call("conductor", del, map[string]any{
|
||||
input := 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
|
||||
@@ -383,6 +398,8 @@ 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 == "" {
|
||||
@@ -467,7 +484,7 @@ func runPlanDelegate(provider string) error {
|
||||
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.")
|
||||
_, err := comms.Ask(ctx, "Send exactly one owner readiness notification now with this exact task: "+delegatedNotifyTask+" Use the notify service and do not answer until the notification has been sent.")
|
||||
return err
|
||||
})},
|
||||
),
|
||||
@@ -537,6 +554,13 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
|
||||
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)
|
||||
}
|
||||
settled, err = waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if !settled {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notifySvc.count())
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
@@ -598,7 +622,7 @@ func isClientTimeout(err error) bool {
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
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")
|
||||
flag.Parse()
|
||||
|
||||
if err := runPlanDelegate(*provider); err != nil {
|
||||
|
||||
@@ -240,6 +240,15 @@ 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"} {
|
||||
@@ -358,6 +367,37 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateRecoveryWaitsForRecoveredNotifySideEffect(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var rsp SendResponse
|
||||
_ = notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
}()
|
||||
return nil
|
||||
})(context.Background(), flow.State{})
|
||||
if err != nil {
|
||||
t.Fatalf("requireDelegatedNotifyStep returned %v, want delayed recovery success", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notify recovery did not run")
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want recovered notify side effect", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
|
||||
@@ -414,7 +414,7 @@ func runUniverse(provider string) int {
|
||||
// Services.
|
||||
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
|
||||
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Broker(br), service.Client(cl))
|
||||
svc.Handle(h)
|
||||
go svc.Run()
|
||||
}
|
||||
@@ -429,6 +429,7 @@ func runUniverse(provider string) int {
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.MaxSteps(5),
|
||||
agent.WithBroker(br),
|
||||
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
atomic.AddInt64(&wrapped, 1)
|
||||
|
||||
@@ -4,19 +4,21 @@ This directory owns the no-secret reference scenario for the Go Micro
|
||||
services → agents → workflows lifecycle. It is intentionally small and
|
||||
scripted so CI can run it on every push without external services or model keys.
|
||||
|
||||
`run.sh` verifies five boundaries together:
|
||||
`run.sh` verifies the complete first-agent 0→hero contract together:
|
||||
|
||||
1. **First agent** — `micro new`, `micro agent preflight`, `micro run`,
|
||||
`micro chat`, and `micro inspect agent <name>` remain available as the
|
||||
documented first-agent walkthrough path.
|
||||
2. **Run** — `micro run` remains available as the local development entry point.
|
||||
3. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
4. **Inspect/debugging** — `micro inspect agent <name>`, `micro agent history <name>`,
|
||||
1. **Scaffold** — the maintained `micro new` 0→1 contract still creates
|
||||
runnable services from a clean workspace.
|
||||
2. **First agent** — `micro agent preflight`, `micro run`, `micro chat`, and
|
||||
`micro inspect agent <name>` remain available as the documented first-agent
|
||||
walkthrough path.
|
||||
3. **Run** — `micro run` remains available as the local development entry point.
|
||||
4. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
5. **Inspect/debugging** — `micro inspect agent <name>`, `micro agent history <name>`,
|
||||
and `micro inspect flow <name>` remain available as the local run-history
|
||||
inspection step. The no-secret debugging smoke seeds durable agent run history
|
||||
and memory, then runs the documented inspect/history commands without provider
|
||||
credentials; `micro flow runs` preserves durable workflow history inspection.
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
6. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
building binaries, opening SSH connections, running `rsync`, or touching
|
||||
@@ -30,16 +32,19 @@ and A2A with only the LLM mocked.
|
||||
|
||||
The default GitHub harness workflow runs this script on every push and pull
|
||||
request after the install smoke check and 0→1 scaffold contract. Developers can
|
||||
verify the installer seam alone with `make install-smoke`, or run the same
|
||||
no-secret contract locally with:
|
||||
verify the first-agent on-ramp links alone with `make docs-wayfinding`, verify
|
||||
the installer seam alone with `make install-smoke`, run just the documented
|
||||
agent debugging quickcheck with
|
||||
`go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`,
|
||||
or run the same no-secret contract locally with:
|
||||
|
||||
```sh
|
||||
make harness
|
||||
```
|
||||
|
||||
That target intentionally exercises the install script smoke path, both 0→1
|
||||
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
|
||||
mock provider conformance, so
|
||||
That target intentionally exercises the first-agent docs wayfinding guard, the
|
||||
install script smoke path, both 0→1 scaffold variants, the 0→hero scenario, the
|
||||
event-driven agent-flow harness, and mock provider conformance, so
|
||||
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
|
||||
outside CI as well. Live provider checks remain separate and gated by configured
|
||||
API keys (`make provider-conformance` or the scheduled/manual CI job).
|
||||
|
||||
@@ -38,6 +38,19 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
runScript := readFile(t, filepath.Join(root, "internal", "harness", "zero-to-hero-ci", "run.sh"))
|
||||
for _, want := range []string{
|
||||
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
|
||||
"go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1",
|
||||
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
|
||||
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
|
||||
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
|
||||
} {
|
||||
if !strings.Contains(runScript, want) {
|
||||
t.Fatalf("0→hero CI run script missing lifecycle command %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
readme := readFile(t, filepath.Join(root, "README.md"))
|
||||
if !strings.Contains(readme, "internal/website/docs/guides/zero-to-hero.md") {
|
||||
t.Fatal("README does not point to the canonical 0→hero guide")
|
||||
@@ -248,6 +261,40 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
|
||||
"guides/install-troubleshooting.html",
|
||||
"micro agent demo",
|
||||
"micro zero-to-hero",
|
||||
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
|
||||
"https://github.com/micro/go-micro/tree/master/examples/support",
|
||||
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
|
||||
"guides/no-secret-first-agent.html",
|
||||
"guides/your-first-agent.html",
|
||||
"guides/debugging-agents.html",
|
||||
"guides/zero-to-hero.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website quickstart next steps",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "quickstart.md"),
|
||||
heading: "## Next Steps",
|
||||
links: []string{
|
||||
"guides/install-troubleshooting.html",
|
||||
"micro agent demo",
|
||||
"micro zero-to-hero",
|
||||
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
|
||||
"https://github.com/micro/go-micro/tree/master/examples/support",
|
||||
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
|
||||
"guides/no-secret-first-agent.html",
|
||||
"guides/your-first-agent.html",
|
||||
"guides/debugging-agents.html",
|
||||
"guides/zero-to-hero.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website docs index learn more",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "index.md"),
|
||||
heading: "## Learn More",
|
||||
links: []string{
|
||||
"getting-started.html",
|
||||
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
|
||||
"https://github.com/micro/go-micro/tree/master/examples/support",
|
||||
"guides/no-secret-first-agent.html",
|
||||
"guides/your-first-agent.html",
|
||||
"guides/debugging-agents.html",
|
||||
@@ -307,6 +354,16 @@ func TestFirstAgentWayfindingLinkTargetsResolve(t *testing.T) {
|
||||
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
|
||||
heading: "### First-agent on-ramp",
|
||||
},
|
||||
{
|
||||
name: "website quickstart next steps",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "quickstart.md"),
|
||||
heading: "## Next Steps",
|
||||
},
|
||||
{
|
||||
name: "website docs index learn more",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "index.md"),
|
||||
heading: "## Learn More",
|
||||
},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
@@ -563,6 +620,24 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
debuggingGuide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "debugging-agents.md"))
|
||||
for _, want := range []string{
|
||||
"Provider-free quickcheck",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
|
||||
"micro inspect agent assistant --limit 1",
|
||||
"micro inspect agent --status done",
|
||||
"micro agent history assistant",
|
||||
} {
|
||||
if !strings.Contains(debuggingGuide, want) {
|
||||
t.Fatalf("debugging guide missing provider-free quickcheck marker %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
harnessReadme := readFile(t, filepath.Join(root, "internal", "harness", "zero-to-hero-ci", "README.md"))
|
||||
if !strings.Contains(harnessReadme, "go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1") {
|
||||
t.Fatal("0→hero harness README does not expose the agent debugging quickcheck command")
|
||||
}
|
||||
|
||||
readme := readFile(t, filepath.Join(root, "README.md"))
|
||||
if !strings.Contains(readme, "internal/website/docs/guides/no-secret-first-agent.md") {
|
||||
t.Fatal("README does not point to the no-secret first-agent transcript")
|
||||
|
||||
@@ -6,6 +6,7 @@ cd "$ROOT"
|
||||
|
||||
# Keep the developer inner-loop boundaries executable and discoverable in CI
|
||||
# without secrets or long-running daemons.
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestYourFirstAgentTutorialSmoke' -count=1
|
||||
|
||||
@@ -59,11 +59,12 @@ After this quick start, follow the agent path in 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. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
|
||||
6. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
7. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
8. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
9. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
5. [Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support), and next interop examples from one map.
|
||||
6. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
|
||||
7. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
8. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
9. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
10. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
|
||||
## Write a Service
|
||||
|
||||
|
||||
@@ -116,6 +116,18 @@ state (`agent/<name>/runs/...`). The persisted timeline is recorded even without
|
||||
an OpenTelemetry exporter, so `micro inspect agent` remains useful in local
|
||||
no-secret development.
|
||||
|
||||
Provider-free quickcheck: if you want to verify the documented inspect path
|
||||
before involving a live model, run the same smoke check CI uses:
|
||||
|
||||
```sh
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
|
||||
```
|
||||
|
||||
That test seeds a local `assistant` run history and memory transcript, then runs
|
||||
`micro inspect agent assistant --limit 1`, `micro inspect agent --status done
|
||||
--json assistant`, and `micro agent history assistant` with provider credentials
|
||||
cleared.
|
||||
|
||||
## 4. See tool calls as they happen
|
||||
|
||||
When you are embedding an agent in Go and need live tool visibility, use the
|
||||
|
||||
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
|
||||
|
||||
## Learn More
|
||||
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: `micro agent demo` for the installed no-secret CLI affordance, `micro examples` for copy/pasteable runnable examples, [No-secret first-agent transcript](guides/no-secret-first-agent.html) to run a mock-model support agent, [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: `micro agent demo` for the installed no-secret CLI affordance, [examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) for the maintained examples map, [the 0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support) for the full no-secret lifecycle example, [No-secret first-agent transcript](guides/no-secret-first-agent.html) to run a mock-model support agent, [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
|
||||
|
||||
Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
@@ -26,6 +26,8 @@ Otherwise continue to read the docs for more information about the framework.
|
||||
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
|
||||
- `micro agent demo` - Show the provider-free first-agent demo command and next docs steps
|
||||
- `micro examples` - Show provider-free first-agent examples in copy/paste order
|
||||
- [Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) - Choose the first-agent, support, and interop examples from one map
|
||||
- [0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support) - Run the maintained no-secret services → agents → workflows example
|
||||
- [No-secret first-agent transcript](guides/no-secret-first-agent.html) - Run the first useful agent path without a provider key
|
||||
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
|
||||
- [MCP & AI Agents](mcp.html) - Turn services into AI-callable tools with the Model Context Protocol
|
||||
|
||||
@@ -45,11 +45,12 @@ You now have the service half of the services → agents → workflows lifecycle
|
||||
2. `micro agent demo` - print the provider-free first-agent demo command and the 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. **[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.
|
||||
6. **[No-secret first-agent transcript](guides/no-secret-first-agent.html)** - run a useful support agent with a mock model before setting up a provider key.
|
||||
7. **[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.
|
||||
8. **[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.
|
||||
9. **[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.
|
||||
5. **[Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md)** - choose the smallest no-secret first-agent, maintained **[0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support)**, and next interop examples from one map.
|
||||
6. **[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.
|
||||
7. **[No-secret first-agent transcript](guides/no-secret-first-agent.html)** - run a useful support agent with a mock model before setting up a provider key.
|
||||
8. **[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.
|
||||
9. **[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.
|
||||
10. **[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:
|
||||
|
||||
|
||||
@@ -220,6 +220,13 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user