Compare commits

..

59 Commits

Author SHA1 Message Date
Codex f4fae71fe3 docs: refresh planner priorities for 4358
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-08 13:44:22 +00:00
Asim Aslam 967e1cfd9b Stabilize conformance marker retry prompt (#4357)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 14:14:06 +01:00
Asim Aslam 02aac71019 docs: refresh planner priorities for 4351 (#4352)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 13:44:20 +01:00
Asim Aslam 415e95c0f1 test agent startup resume checkpoints (#4350)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 13:20:15 +01:00
Asim Aslam 36c42d0cfa docs: refresh planner priorities for 4345 (#4346)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 12:50:35 +01:00
Asim Aslam 459dff8e7a ci: add first-agent CLI wayfinding target (#4344)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 12:26:26 +01:00
Asim Aslam 6c74ae0f9c docs: refresh planner priorities for 4340 (#4342)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 11:55:34 +01:00
Asim Aslam 224e107f1c Stabilize plan-delegate notify recovery (#4339)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 11:28:41 +01:00
Asim Aslam e0b9f1323f docs: refresh planner priorities for 4335 (#4337)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 11:00:58 +01:00
Asim Aslam 35d3d99428 Fail agent-flow on missing onboarding side effects (#4334)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 10:30:24 +01:00
Asim Aslam 656c0ce9d6 docs: refresh planner priorities for 4331 (#4332)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 10:05:26 +01:00
Asim Aslam 7734f5605d Verify zero-to-hero deploy dry-run (#4330)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 09:26:45 +01:00
Asim Aslam affd61e69b docs: refresh coherence changelog (#4326)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 08:57:36 +01:00
Asim Aslam 171da77817 docs: refresh planner priorities for 4319 (#4320)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 08:03:31 +01:00
Asim Aslam 83a6b2004a Propagate provider HTTP retry signals (#4318)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 06:43:28 +01:00
Asim Aslam ae5ead7914 docs: refresh planner priorities for 4314 (#4316)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 06:16:36 +01:00
Asim Aslam a1d17ec984 Avoid recording failed stream turns (#4313)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 04:57:10 +01:00
Asim Aslam a8b89c2142 docs: refresh planner priorities for 4308 (#4310)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 04:06:25 +01:00
Asim Aslam 76fea3f01c agent: parse function-style text tool calls (#4307)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 03:29:12 +01:00
Asim Aslam a5f8ff518d docs: refresh planner priorities for 4304 (#4305)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 02:40:13 +01:00
Asim Aslam cf9c015cf8 ci: add docs wayfinding guard (#4303)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 02:12:09 +01:00
Asim Aslam 7248270f28 docs: refresh planner priorities for 4300 (#4301)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 01:48:45 +01:00
Asim Aslam 243d7904e8 Fix plan-delegate notify recovery (#4299)
Co-authored-by: Codex <codex@openai.com>
2026-07-08 01:00:03 +01:00
Asim Aslam 364fb33cc8 docs: refresh planner priorities for 4290 (#4292)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-07 23:37:43 +01:00
Asim Aslam 7697b2587c Wire universe harness services to shared broker (#4289)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 23:08:10 +01:00
Asim Aslam fa36114d73 docs: refresh planner priorities for 4286 (#4287)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 22:44:12 +01:00
Asim Aslam 5c5186a230 Add agent debugging quickcheck docs (#4285)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 22:17:15 +01:00
Asim Aslam 58a76bc32f docs: refresh planner priorities for 4280 (#4281)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 21:48:53 +01:00
Asim Aslam 2c562c8827 Add scaffold check to zero-to-hero harness (#4279)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 21:24:27 +01:00
Asim Aslam ff1c6173cd docs: refresh planner priorities for 4271 (#4275)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 20:41:27 +01:00
Asim Aslam 2c7b2dbe51 Harden text tool call parsing for AtlasCloud (#4270)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 20:24:31 +01:00
Asim Aslam c51d53f5ec docs: refresh planner priorities for 4267 (#4268)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 19:55:16 +01:00
Asim Aslam 6a25bbfc05 docs: link website first-agent examples map (#4266)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 19:24:22 +01:00
Asim Aslam ff1bf69252 docs: refresh planner priorities for 4263 (#4264)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 18:51:28 +01:00
Asim Aslam 92853d353e fix atlascloud multi-step tool followups (#4262)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 18:28:59 +01:00
Asim Aslam 391dc1cf05 docs: refresh planner priorities for 4258 (#4259)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 17:59:36 +01:00
Asim Aslam ae69583f34 Parse OpenAI-compatible text tool calls (#4257)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 16:40:58 +01:00
Asim Aslam e2729b2c32 docs: refresh planner queue for 4252 (#4253)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 16:13:07 +01:00
Asim Aslam ef46bfc37f Prevent duplicate delegate replays (#4251)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 15:30:36 +01:00
Asim Aslam 81d61f93d0 docs: refresh planner priorities for 4248 (#4249)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 14:56:26 +01:00
Asim Aslam 5dc2a32233 fix atlascloud minimax tool fallback (#4247)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 14:30:38 +01:00
Asim Aslam d8562edcf6 docs: refresh planner priorities for 4240 (#4242)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 14:02:07 +01:00
Asim Aslam 5a85cba982 docs: add examples wayfinding index (#4239)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 13:34:38 +01:00
Asim Aslam 019420c123 docs: refresh planner priorities for 4233 (#4234)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 12:13:53 +01:00
Asim Aslam 5b32014de1 agent: trace tool retry attempts (#4232)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 10:58:59 +01:00
Asim Aslam 32e522a2a9 docs: refresh planner priorities for 4229 (#4230)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 10:18:29 +01:00
Asim Aslam 811d617b46 docs: refresh coherence changelog (#4228)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 09:15:15 +01:00
Asim Aslam 32d0c46676 agent: cancel stream ask on close (#4226)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 09:05:08 +01:00
Asim Aslam 34e7cf1f5f docs: refresh planner priorities for 4222 (#4224)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 08:26:57 +01:00
Asim Aslam 091fb1d4df agent: add resume pending helper (#4221)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 07:05:27 +01:00
Asim Aslam 1c56d77c86 docs: refresh planner priorities for 4216 (#4219)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 06:32:27 +01:00
Asim Aslam 6f220089d5 test agent retry side-effect dedupe (#4215)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 04:57:01 +01:00
Asim Aslam 58249e4a2f docs: refresh planner priorities for 4212 (#4213)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 04:19:05 +01:00
Asim Aslam e51cbfba01 agent: harden conformance delegate retry (#4211)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 03:30:56 +01:00
Asim Aslam e68d0e0018 docs: refresh planner priorities for 4208 (#4209)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 02:48:36 +01:00
Asim Aslam e96898d362 test: verify installed on-ramp wayfinding (#4207)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 02:20:46 +01:00
Asim Aslam c88a090d10 docs: refresh planner priorities for 4201 (#4203)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 01:53:07 +01:00
Asim Aslam f6951d2bb0 Enforce idempotent delegate notifications (#4200)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 01:03:54 +01:00
Asim Aslam 94286204ad docs: refresh planner priorities for 4196 (#4198)
Co-authored-by: Codex <codex@openai.com>
2026-07-07 00:41:56 +01:00
46 changed files with 1890 additions and 160 deletions
+3 -3
View File
@@ -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. **Handle AtlasCloud Minimax 400s for native service tools** ([#4354](https://github.com/micro/go-micro/issues/4354)) — The previous AtlasCloud marker retry item (#4348) shipped, but the live provider has now exposed an earlier Now-phase conformance break: Minimax rejects native service-tool payloads in the agent, universe, and A2A stream fallback harnesses. Fixing the single-service-tool path restores the services-as-tools promise behind first-agent trust and unblocks several live adoption-facing examples without changing public APIs.
2. **Fix AtlasCloud Minimax tool follow-up retry after tool results** ([#4355](https://github.com/micro/go-micro/issues/4355)) — Once native tool payloads are accepted, the next AtlasCloud gap is the follow-up retry transcript after a tool result. This protects plan/delegate plus service side effects from failing after partial work, directly supporting the services → agents → workflows lifecycle under real provider behavior.
3. **Trace agent RunInfo in OpenTelemetry spans** ([#4315](https://github.com/micro/go-micro/issues/4315)) — After the live conformance regressions are stable, the highest Next-phase operability gap is connecting existing run metadata to traces so real agent runs can be debugged across steps, tool calls, delegation, failures, services, and flows without inventing a new surface.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+43
View File
@@ -16,17 +16,60 @@ next version when it ships.
## [Unreleased]
### Added
- **Provider HTTP retry signals** — provider failures now preserve HTTP status and `Retry-After` details so retry classification and backoff can respond to rate limits and unavailable providers. (`ai/`)
### Fixed
- **Stream fallback memory** — unsupported streaming attempts no longer leave stale duplicate user turns before fallback paths continue with non-streaming agent calls. (`agent/`)
- **Function-style text tool calls** — agent fallback parsing now recognizes provider replies that render tools as function-style calls, including nested JSON arguments. (`agent/`)
- **Plan/delegate notify recovery** — plan-delegate recovery now waits for recovered notify side effects and routes retries through the communications agent that owns the notification. (`internal/harness/`)
### Documentation
- **First-agent docs wayfinding guard** — the local harness now includes a focused no-network check for first-agent and 0→hero docs links. (`Makefile`, `internal/harness/`)
---
## [6.3.18] - July 2026
### 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/`)
- **Agent tool retry tracing** — agent traces now include tool retry attempts for easier debugging of retry/fallback behavior. (`agent/`)
- **Shared-broker universe harness** — the universe harness now runs against the shared broker path, improving coverage of the same runtime wiring used by services, agents, and workflows. (`internal/harness/`)
### 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/`)
- **AtlasCloud text tool calls** — AtlasCloud fallback handling now recovers more text-rendered tool calls from OpenAI-compatible responses. (`ai/atlascloud/`, `agent/`)
- **OpenAI-compatible text tool calls** — OpenAI-compatible providers now recover text-rendered tool calls more reliably. (`agent/`)
- **AtlasCloud multi-step follow-ups** — AtlasCloud tool fallback handling now continues multi-step tool follow-up paths more reliably. (`ai/atlascloud/`, `agent/`)
### Documentation
- **Agent debugging quickcheck** — docs now include a focused quickcheck path for first-agent debugging. (`internal/website/docs/`)
- **Website first-agent examples map** — website docs now link the maintained examples wayfinding map for the first-agent route. (`internal/website/docs/`)
- **Examples wayfinding index** — examples docs now provide a central map for first-agent, support, and interop examples. (`examples/`, `internal/website/docs/`)
---
## [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/`)
---
+18 -3
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness 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 cli-wayfinding 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,8 @@ 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 cli-wayfinding - Verify installed first-agent CLI wayfinding commands"
@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 +51,26 @@ test-coverage:
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) install-smoke
$(MAKE) cli-wayfinding
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 installed CLI keeps the first-agent on-ramp commands discoverable.
# This guards the no-secret commands README/docs recommend (`micro agent demo`,
# `micro examples`, and `micro zero-to-hero`) as a CI contract.
cli-wayfinding:
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfinding' -count=1
$(MAKE) docs-wayfinding
$(MAKE) install-smoke
# 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 +126,3 @@ gorelease-dry-run:
-w /$(NAME) \
$(GORELEASER_DOCKER_IMAGE) \
--clean --verbose --skip=publish,validate --snapshot
+6 -5
View File
@@ -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.
+35 -2
View File
@@ -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.
@@ -221,16 +227,18 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
messages := append([]ai.Message(nil), a.mem.Messages()...)
messages = append(messages, ai.Message{Role: "user", Content: message})
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: a.mem.Messages(),
Messages: messages,
})
if err != nil {
return nil, err
}
a.mem.Add("user", message)
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
@@ -244,6 +252,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
View File
@@ -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
+65
View File
@@ -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()
+111
View File
@@ -5,6 +5,7 @@ import (
"errors"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
@@ -396,6 +397,71 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
}
}
func TestResumePendingAfterFreshAgentRestartDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "startup-resume-agent")
toolRuns := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.allocate", Input: map[string]any{"cluster": "blue"}})
if res.Content != "allocated" {
t.Fatalf("tool result = %q, want allocated", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped before final response")
}
return &ai.Response{Reply: "startup recovery complete"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("startup-resume-agent"), WithStore(st), WithCheckpoint(cp),
WithTool("external.allocate", "allocate capacity once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "allocated", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "allocate blue capacity")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
restarted := newAgent()
failedRun, err := ResumePending(ctx, restarted)
if err != nil {
t.Fatalf("ResumePending after restart: failedRun=%q err=%v", failedRun, err)
}
if failedRun != "" {
t.Fatalf("failed run = %q, want none", failedRun)
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumePending = %d, want completed tool not replayed", toolRuns)
}
runs, err := Pending(ctx, restarted)
if err != nil {
t.Fatalf("Pending after ResumePending: %v", err)
}
if len(runs) != 0 {
t.Fatalf("Pending after ResumePending = %#v, want none", runs)
}
summaries, err := ListRunSummaries(st, "startup-resume-agent")
if err != nil {
t.Fatalf("ListRunSummaries after ResumePending: %v", err)
}
if len(summaries) != 1 || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" {
t.Fatalf("summary after ResumePending = %#v, want one done run", summaries)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
@@ -462,6 +528,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")
+37 -7
View File
@@ -180,7 +180,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
}
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
const maxAttempts = 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,14 +274,44 @@ 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 TestAgentProviderConformanceMarkerRetryRequiresExactMarkerReply(t *testing.T) {
prompt := nextConformanceRetryPrompt(true, true, false, 2)
for _, want := range []string{
"omitted the conformance marker",
"do not call more tools",
"do not summarize",
"Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal.",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("marker 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."
case !sawBlockedDelegate:
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !hasMarker:
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools, do not summarize, and do not use synonyms. Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal."
default:
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
}
@@ -549,7 +579,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
View File
@@ -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))
+76
View File
@@ -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()
+56
View File
@@ -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
View File
@@ -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
}
+57
View File
@@ -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 {
@@ -209,6 +243,29 @@ func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestAgentStreamDoesNotRecordUserWhenProviderStreamingUnsupported(t *testing.T) {
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != "stream fallback" {
t.Fatalf("stream request messages = %+v, want pending user message", req.Messages)
}
return nil, ai.ErrStreamingUnsupported
}
defer func() { fakeStream = nil }()
mem := NewInMemory(8)
a := newTestAgent(Name("stream-fallback"), WithMemory(mem), WithTool("echo", "echo text", nil, func(context.Context, map[string]any) (string, error) {
return "ok", nil
}))
_, err := a.Stream(context.Background(), "stream fallback")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if got := mem.Messages(); len(got) != 0 {
t.Fatalf("memory after unsupported stream = %+v, want no recorded messages", got)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
+165 -18
View File
@@ -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) {
+90
View File
@@ -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 := `&lt;tool_call name=&quot;delegate&quot;&gt;{"task":"summarize the conformance marker","to":"blocked-reviewer"}&lt;/tool_call&gt;`
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)
}
}
+1 -1
View File
@@ -297,7 +297,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
// Parse response
+146 -52
View File
@@ -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,33 @@ func (s *atlasStream) Close() error {
return s.body.Close()
}
type atlascloudAPIError struct {
Status string
Code int
Retry time.Duration
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 (e *atlascloudAPIError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *atlascloudAPIError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return e.Retry
}
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 +376,12 @@ 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))
retryAfter := time.Duration(0)
var retryErr interface{ RetryAfter() time.Duration }
if errors.As(ai.NewHTTPError(httpResp, respBody), &retryErr) {
retryAfter = retryErr.RetryAfter()
}
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, Code: httpResp.StatusCode, Retry: retryAfter, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
}
var chatResp struct {
@@ -376,6 +426,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 {
+180
View File
@@ -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)
+1 -1
View File
@@ -163,7 +163,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
var geminiResp struct {
+1 -1
View File
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
var chatResp struct {
+1 -1
View File
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
var chatResp struct {
+1 -1
View File
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
var chatResp struct {
+1 -1
View File
@@ -285,7 +285,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
// Parse response
+60
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
@@ -19,6 +21,64 @@ type RetryAfterCoder interface {
RetryAfter() time.Duration
}
// HTTPError describes a failed provider HTTP response while preserving the
// status code and Retry-After signal for retry classifiers.
type HTTPError struct {
Status string
Code int
Body string
Header http.Header
}
func (e *HTTPError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("API error (%s): %s", e.Status, e.Body)
}
func (e *HTTPError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *HTTPError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return parseRetryAfter(e.Header.Get("Retry-After"), time.Now())
}
func NewHTTPError(resp *http.Response, body []byte) error {
if resp == nil {
return errors.New("API error: nil response")
}
return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: string(body), Header: resp.Header.Clone()}
}
func parseRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds <= 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
when, err := http.ParseTime(value)
if err != nil {
return 0
}
if delay := when.Sub(now); delay > 0 {
return delay
}
return 0
}
// ErrorKind classifies provider-boundary failures into stable buckets callers
// can inspect without parsing provider-specific error strings.
type ErrorKind string
+21
View File
@@ -3,6 +3,7 @@ package ai
import (
"context"
"errors"
"net/http"
"testing"
"time"
)
@@ -221,3 +222,23 @@ func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
}
}
func TestHTTPErrorExposesStatusAndRetryAfter(t *testing.T) {
resp := &http.Response{
Status: "429 Too Many Requests",
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"2"}},
}
err := NewHTTPError(resp, []byte("slow down"))
if got := ClassifyError(err); got != ErrorKindRateLimited {
t.Fatalf("ClassifyError() = %q, want %q", got, ErrorKindRateLimited)
}
var retryAfter RetryAfterCoder
if !errors.As(err, &retryAfter) {
t.Fatalf("NewHTTPError does not expose RetryAfterCoder")
}
if got := retryAfter.RetryAfter(); got != 2*time.Second {
t.Fatalf("RetryAfter() = %s, want 2s", got)
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, ai.NewHTTPError(httpResp, respBody)
}
var chatResp struct {
+2 -1
View File
@@ -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
+71
View File
@@ -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.
}
+46
View File
@@ -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
View File
@@ -7,8 +7,8 @@ coordinate work with workflows.
## Quick Start
Each example can be run with `go run .` from its directory unless its README says
otherwise. If you are new to the repo, 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
+29 -10
View File
@@ -178,6 +178,24 @@ func waitFor(reg registry.Registry, name string) {
}
}
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
workspaces, notifications := wsSvc.count(), ntSvc.count()
if workspaces >= 1 && notifications >= 1 {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("agent-flow missing required onboarding side effects before timeout: workspaces=%d/1 notifications=%d/1", workspaces, notifications)
case <-ticker.C:
}
}
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
@@ -257,20 +275,21 @@ func main() {
os.Exit(1)
}
// Wait for the agent to finish acting.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if ntSvc.count() >= 1 && wsSvc.count() >= 1 {
break
}
time.Sleep(50 * time.Millisecond)
}
// Wait for the agent to finish acting, and fail the harness if the
// provider returns a successful reply without the required service side
// effects. The 0→hero/provider conformance path must not print success
// unless the services → agent → workflow contract actually happened.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
cancel()
fmt.Printf("\n\033[1mresult:\033[0m workspaces created=%d, notifications sent=%d\n", wsSvc.count(), ntSvc.count())
if rs := f.Results(); len(rs) > 0 {
fmt.Printf("flow reply: %s\n", rs[len(rs)-1].Reply)
}
if wsSvc.count() >= 1 && ntSvc.count() >= 1 {
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
if err != nil {
fmt.Printf("\n\033[31m✗ %v\033[0m\n", err)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
}
+36
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -99,3 +101,37 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
t.Errorf("flow recorded no result for the event")
}
}
func TestWaitForOnboardingSideEffectsFailsWhenMissing(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
if err == nil {
t.Fatal("waitForOnboardingSideEffects returned nil, want missing side effects error")
}
if got := err.Error(); !strings.Contains(got, "workspaces=0/1") || !strings.Contains(got, "notifications=0/1") {
t.Fatalf("waitForOnboardingSideEffects error %q does not report missing side effects", got)
}
}
func TestWaitForOnboardingSideEffectsPassesWhenComplete(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := ntSvc.Send(context.Background(), &SendRequest{To: "alice@acme.com", Message: "Welcome"}, &SendResponse{}); err != nil {
t.Fatalf("send notification: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc); err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want nil", err)
}
}
+75
View File
@@ -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"
+41 -6
View File
@@ -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 == "" {
@@ -465,9 +482,9 @@ func runPlanDelegate(provider string) error {
f := flow.New("zero-to-hero",
flow.Steps(
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor)},
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor, taskSvc, notifySvc)},
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
})},
),
@@ -504,11 +521,15 @@ func runPlanDelegate(provider string) error {
return nil
}
func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, notifySvc *NotifyService) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
rsp, err := conductor.Ask(ctx, prompt)
if err != nil {
if isUnfinishedPlanError(err) && taskSvc != nil && notifySvc != nil && taskSvc.count() == 3 && notifySvc.count() == 0 {
fmt.Printf("\n\033[33mwarning:\033[0m conductor stopped with unfinished delegation after creating tasks; continuing to require-notify recovery: %v\n", err)
return in, nil
}
return in, err
}
if rsp != nil && rsp.Reply != "" {
@@ -518,6 +539,13 @@ func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
}
}
func isUnfinishedPlanError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unfinished plan steps")
}
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
tasks := taskSvc.count()
@@ -537,6 +565,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 +633,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"} {
@@ -292,6 +301,45 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
}
}
type failingAgent struct {
err error
}
func (a failingAgent) Name() string { return "failing" }
func (a failingAgent) Init(...agent.Option) {}
func (a failingAgent) Options() agent.Options { return agent.Options{} }
func (a failingAgent) Ask(context.Context, string) (*agent.Response, error) { return nil, a.err }
func (a failingAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, a.err }
func (a failingAgent) Run() error { return nil }
func (a failingAgent) Stop() error { return nil }
func (a failingAgent) String() string { return "failing" }
func TestPlanDelegateConductorAllowsNotifyRecoveryAfterUnfinishedDelegation(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)
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")}, taskSvc, notifySvc)
if _, err := step(context.Background(), flow.State{}); err != nil {
t.Fatalf("planDelegateConductorStep returned %v, want require-notify recovery to run", err)
}
}
func TestPlanDelegateConductorKeepsUnfinishedTaskFailureActionable(t *testing.T) {
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Create Build task")}, new(TaskService), new(NotifyService))
err := func() error { _, err := step(context.Background(), flow.State{}); return err }()
if err == nil {
t.Fatal("planDelegateConductorStep returned nil, want unfinished task error")
}
if got := err.Error(); !strings.Contains(got, "Create Build task") {
t.Fatalf("error = %q, want original unfinished task detail", got)
}
}
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
@@ -358,6 +406,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"} {
+2 -1
View File
@@ -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)
+18 -13
View File
@@ -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")
@@ -49,6 +62,62 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
}
}
func TestZeroToHeroDeployDryRunCommandSmoke(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
absRoot, err := filepath.Abs(root)
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
bin := filepath.Join(t.TempDir(), "micro")
build := exec.Command("go", "build", "-o", bin, "./cmd/micro")
build.Dir = absRoot
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build micro CLI for deploy dry-run smoke: %v\n%s", err, out)
}
workspace := t.TempDir()
writeFile(t, filepath.Join(workspace, "micro.mu"), `service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`)
if err := os.Mkdir(filepath.Join(workspace, "api"), 0o755); err != nil {
t.Fatalf("create service dir: %v", err)
}
cmd := exec.Command(bin, "deploy", "--dry-run", "prod")
cmd.Dir = workspace
cmd.Env = append(os.Environ(), "MICRO_CONFIG_FILE="+filepath.Join(workspace, "micro.mu"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("documented deploy dry-run command failed: %v\n%s", err, out)
}
got := string(out)
for _, want := range []string{
"micro deploy --dry-run",
"Target",
"deploy@prod.example.com",
"Remote path",
"/srv/micro",
"Services",
"api",
"No SSH, rsync, systemd, or remote deployment was performed.",
} {
if !strings.Contains(got, want) {
t.Fatalf("deploy dry-run output missing %q:\n%s", want, got)
}
}
guide := readFile(t, filepath.Join(absRoot, "internal", "website", "docs", "guides", "zero-to-hero.md"))
if !strings.Contains(guide, "micro deploy --dry-run prod") {
t.Fatal("0→hero guide must document the same deploy dry-run command covered by CI")
}
}
func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
@@ -248,6 +317,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 +410,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 +676,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")
+2 -1
View File
@@ -6,9 +6,10 @@ 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
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider. The support example is the maintained
+6 -5
View File
@@ -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
+2 -1
View File
@@ -23,7 +23,7 @@ cloud credentials?"
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Inspect | `micro inspect agent <name>`, `micro agent history <name>`, `micro inspect flow <flow>`, and `micro flow runs <flow>` remain discoverable for run history; the no-secret debugging smoke seeds durable agent history and runs the documented inspect/history commands without provider keys. | `go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1` |
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
| Deploy | `micro deploy --dry-run prod` resolves the documented deploy target without touching remote infrastructure. | `go test ./internal/harness/zero-to-hero-ci -run TestZeroToHeroDeployDryRunCommandSmoke -count=1` |
| Smallest first agent | `examples/first-agent` runs one service-backed agent with a deterministic mock model and no provider key. | `go test ./examples/first-agent -run TestRunFirstAgent -count=1` |
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
@@ -84,6 +84,7 @@ go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
go test ./internal/harness/zero-to-hero-ci -run TestZeroToHeroDeployDryRunCommandSmoke -count=1
# Smallest no-secret service-backed first agent.
go test ./examples/first-agent -run TestRunFirstAgent -count=1
+3 -1
View File
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
## Learn More
Start with [Getting Started](getting-started.html) for install and the first local service. 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
+6 -5
View File
@@ -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:
+7
View File
@@ -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)