Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 84aede25a0 Repair partial text tool calls
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-09 07:59:12 +00:00
142 changed files with 705 additions and 6199 deletions
+22 -33
View File
@@ -1,41 +1,30 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The **planner** owns
this file: each run it turns the [roadmap](../../ROADMAP.md) plus an internal scan
into a single ordered list — highest-value first — each item linked to a tracking
issue. The **builder** works the top item whose issue is still open. So the
planner decides *what*, the builder *builds* it.
The ranked work queue for the autonomous improvement loop. The
**architecture-review** pass (the *architect*) owns this file: each run it turns
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
DX friction) into a single ordered list — highest-value first — and links each
item to a tracking issue. The hourly **continuous-improvement** pass works the
**top item whose issue is still open**. So the architect decides *what*, and the
increment loop *builds* it.
**Bias to capability, not busy-work.** The top of this queue is net-new capability
from the roadmap's *Now/Next* items. Hardening/conformance/DX polish is background
work (roadmap *Ongoing*) — kept low here and capped, never allowed to crowd out
capability. If an area has had several increments with no user-visible gain, it is done
for now; rank real-headroom capability instead.
**Reading / editing.** An item is done when its linked issue closes (the increment
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
primary ordering; internal findings are interleaved by value, not kept in a
separate list. The human can reorder this list — or the issues — at any time to
redirect the loop; direction always wins.
**Reading / editing.** An item is done when its linked issue closes (the PR that
builds it adds `Closes #<issue>`). The human can reorder this list or the issues at
any time — direction always wins.
**Off-limits to the loop** (planner proposes as notes, never auto-merged queue
items): brand/positioning copy, breaking public-API changes, architectural
rewrites.
**Off-limits to the loop** (the architect proposes these as notes, never as queue
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
### Capability — the headline (roadmap: Now / Next)
1. **Handle AtlasCloud plan-delegate partial tool calls** ([#4431](https://github.com/micro/go-micro/issues/4431)) — A current live conformance failure can let AtlasCloud/minimax stop at a partial XML-style tool call, so delegation never produces the required task/notify side effects. This is the highest Now-phase risk because plan/delegate is the core services → agents → workflows seam and must fail or recover deterministically across providers.
2. **Verify first-agent docs wayfinding stays in lockstep** ([#4441](https://github.com/micro/go-micro/issues/4441)) — Developer adoption remains the current goal after the 0→1/0→hero harness landed. Keep README, website guides, examples, and CLI breadcrumbs aligned so the no-secret first-agent path stays discoverable instead of becoming a stale documentation promise.
3. **Fix AtlasCloud tool streaming capability mismatch** ([#4438](https://github.com/micro/go-micro/issues/4438)) — The provider matrix currently advertises AtlasCloud streaming capability beyond its tool-streaming implementation, causing the live agent streaming conformance path to run an unsupported assertion. Fixing the capability/implementation seam keeps streaming honest without blocking no-secret adoption work.
4. **Broaden provider streaming conformance** ([#4386](https://github.com/micro/go-micro/issues/4386)) — Once the provider-specific AtlasCloud mismatch is resolved, expand the broader streaming matrix so chat, agent RPC, and A2A streaming regressions are caught across keyed providers while local CI continues to skip cleanly without secrets.
1. **A2A external-client conformance** ([#4815](https://github.com/micro/go-micro/issues/4815)) — make the gateway easier for non-go-micro agents to discover and stream from by serving the well-known agent card path and spec SSE events.
2. **AP2 mandate foundation for agent payments** ([#4841](https://github.com/micro/go-micro/issues/4841)) — add opt-in checkout/payment mandate signing and verification so A2A-carried payment authority can settle over x402 without changing defaults.
3. **Kubernetes CRD reconciler foundation** ([#4842](https://github.com/micro/go-micro/issues/4842)) — turn the shipped alpha `Agent`, `Service`, and `Flow` CRDs into a minimally runnable native deployment path with workload reconciliation and status conditions.
### In flight — do not re-queue
_None right now._
### Background — hardening & DX (roadmap: Ongoing; capped)
_Background hardening is intentionally empty right now. Recent work covered first-agent
wayfinding, plan/delegate recovery, provider fallback repair, streaming, memory
compaction, retry controls, provider-failure inspection, x402 buyer safety, gRPC-reflection MCP,
MCP result conformance, and the alpha Kubernetes CRD surface. Further churn in those
areas should be marked `needs-human` unless it unlocks a clear user-visible capability._
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+4 -8
View File
@@ -7,14 +7,10 @@ Act as the architect — the founder lens — for go-micro, running continuously
(1) TRACK STATE — scan recently merged PRs and open `codex` PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work).
(2) ASSESS against the North Star in `.github/loop/NORTH_STAR.md` — lead with its Mission (*make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON: the blog under `internal/website/blog`, the `README`, and the website (read these, don't rely on the North Star alone), then `ROADMAP.md` (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? Weight real user-facing capability and the developer on-ramp; do not let the queue fill with internal depth work. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry) and the dev inner loop (scaffold → run → chat → inspect → deploy).
(2) ASSESS against the North Star in `.github/loop/NORTH_STAR.md` — lead with its Mission (*make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON: the blog under `internal/website/blog`, the `README`, and the website (read these, don't rely on the North Star alone), then `ROADMAP.md` (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — developer adoption: weight the on-ramp (walkable first-agent tutorial, discoverable examples, docs wayfinding, install friction, debugging, 0→1 and 0→hero) at least as highly as internal hardening; do not let the queue fill entirely with internal depth work. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry) and the dev inner loop (scaffold → run → chat → inspect → deploy). Flag drift in either direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog.
AVOID DIMINISHING-RETURNS CHURN — this is the most important judgment you make. Before ranking anything, ask: *would a real user notice this, or is it the loop grooming itself?* Do NOT queue: another regression-guard/breadcrumb/"verify the docs stay linked" test around docs the loop already wrote; the Nth robustness workaround for a weak provider's malformed output (e.g. AtlasCloud text-tool-call repair) once the agent already tolerates that class; another variation of a subsystem that has been hardened several times recently (e.g. plan/delegate notify/side-effect edge cases). If an area has had several increments with no user-visible gain, it is DONE for now — mark further work there `needs-human` and rank something with real headroom instead (new capability in gateway/flow/model/store, interop depth, observability). A full queue is not the goal; a queue of things that matter is.
(3) MAINTAIN THE QUEUE in `.github/loop/PRIORITIES.md` — a SINGLE ordered list, highest-value first, each item linking a scoped, CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap with no issue, file one: `gh issue create --label codex --label enhancement --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
(3) MAINTAIN THE QUEUE in `.github/loop/PRIORITIES.md` — a SINGLE ordered list, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap with no issue, file one: `gh issue create --label codex --label enhancement --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
OUTPUT: post a concise assessment as a comment on this issue (#__ISSUE__) — what shipped, what's in flight, the top risks/gaps, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for `.github/loop/PRIORITIES.md`: `git switch -c codex/planner-__ISSUE__`, `git push -u origin codex/planner-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
OUTPUT — default to NOT committing. Post a concise assessment as a comment on this issue (#__ISSUE__): what shipped, what's in flight, the top real gaps, and — honestly — whether the recent increments have been high-value or busy-work. Then, in almost all cases, just close this issue (`gh issue close __ISSUE__`) with NO PR.
Open a PR for `.github/loop/PRIORITIES.md` ONLY when the change is MATERIAL — meaning it changes what the builder builds next: (a) the top open item changes, (b) an item is added or removed, or (c) a top item's issue closed and must be dropped. Do NOT open a PR to reorder items below the top, reword descriptions, refresh notes, or "keep it current" — a re-rank that doesn't change the next build is not worth a commit, and this churn is the loop's single biggest waste. When a PR IS warranted: `git switch -c codex/planner-__ISSUE__`, `git push -u origin codex/planner-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`.
Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human. Open the PR yourself from the shell with `gh`; do not use the make_pr tool (it is a no-op stub).
Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Open the PR yourself from the shell with `gh`; do not use the make_pr tool (it is a no-op stub).
+4 -9
View File
@@ -3,17 +3,12 @@ The TRIAGE prompt — go-micro's CI-failure feedback path. Editable policy; the
workflow prepends the agent @mention and substitutes __ISSUE__ (this tracking
issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
-->
Triage the failed CI run at __RUNURL__. It may be the linter (Lint), the unit/integration tests (Run Tests), the vulnerability gate (govulncheck), or the provider-conformance harness (Harness (E2E)).
Triage the failed CI run at __RUNURL__. It may be the linter (Lint), the unit/integration tests (Run Tests), or the provider-conformance harness (Harness (E2E)).
Read the logs and root-cause each distinct failure. DEDUPE hard against open AND recently-closed issues — if a failure matches an existing or recurring one, comment "recurred" on that issue rather than filing a new one.
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
WHAT TO FILE:
- **Lint, Run Tests, or govulncheck failing on master** — a real regression. File a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance>"`) so it is fixed promptly.
- **A genuinely NEW, distinct provider-conformance defect** — file it.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next CI/harness run verifies it. A lint or test failure on master is a real regression — file it so it is fixed promptly; do NOT ignore it.
WHAT NOT TO FILE (this cap matters):
- **Another instance of a class the agent already tolerates** — a weak provider (e.g. AtlasCloud) emitting malformed / text-rendered / partial tool calls, or another plan/delegate notify/side-effect edge case. These have been hardened repeatedly with diminishing returns. Do NOT auto-file yet another routine robustness patch. Comment "recurred — repeated class, capped" on the nearest existing issue and, if it seems genuinely worth more investment, label it `needs-human` for a human to decide. The loop should not keep chasing one weak provider's output shape.
- **Transient flakes** — live-model latency, provider outages, rate limits, network timeouts with no code cause. Ignore.
- **Anything needing a breaking or architectural change** — label `needs-human` and describe it.
IGNORE only genuine transient flakes — live-model latency, provider outages, rate limits, network timeouts with no code cause (mostly relevant to the harness). Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done. Open any PR yourself from the shell with `gh`; do not use the make_pr tool.
-65
View File
@@ -1,65 +0,0 @@
name: govulncheck
# Deterministic vulnerability gate: runs govulncheck (reachability-aware CVE
# scanner) on every push/PR. Fails on any reachable vulnerability EXCEPT the
# explicit ALLOWLIST of known-unfixable ones, so a new vuln breaks the build
# while tracked, no-upstream-fix ones don't. This is the gate the loop's
# `security` role sits on top of — the role audits; this blocks known CVEs.
#
# Make this a required status check on the default branch to enforce it.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
permissions:
contents: read
jobs:
govulncheck:
name: govulncheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
check-latest: true
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Scan
env:
# Reachable vulnerabilities with NO upstream fix, accepted for now and
# tracked for remediation. Remove an ID the moment its fix lands.
# GO-2026-5004 github.com/jackc/pgx/v4 -> pgx v5 migration (#4556)
# GO-2026-4518 github.com/jackc/pgproto3/v2 -> pgx v5 migration (#4556)
ALLOWLIST: "GO-2026-5004 GO-2026-4518"
run: |
out=$(mktemp)
govulncheck ./... >"$out" 2>&1 && code=0 || code=$?
cat "$out"
if [ "$code" -eq 0 ]; then
echo "govulncheck: no reachable vulnerabilities."
exit 0
fi
if [ "$code" -ne 3 ]; then
echo "::error::govulncheck failed to run (exit $code)."
exit 1
fi
found=$(grep -oE 'Vulnerability #[0-9]+: GO-[0-9]{4}-[0-9]+' "$out" | grep -oE 'GO-[0-9]{4}-[0-9]+' | sort -u)
unexpected=""
for id in $found; do
case " $ALLOWLIST " in
*" $id "*) ;;
*) unexpected="$unexpected $id" ;;
esac
done
if [ -n "$unexpected" ]; then
echo "::error::Unexpected reachable vulnerabilities:$unexpected"
echo "If a fix exists, bump the dependency/toolchain. If genuinely unfixable, add the ID to ALLOWLIST with a tracking issue."
exit 1
fi
echo "govulncheck: only allow-listed (known-unfixable) vulnerabilities present:$found"
echo "OK."
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: 1.24
check-latest: true
cache: true
- name: golangci-lint
+1 -1
View File
@@ -8,7 +8,7 @@ name: "Loop: Triage"
on:
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
types: [completed]
permissions:
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.25"
go-version: 1.24
check-latest: true
cache: true
- name: Get dependencies
@@ -56,7 +56,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.25"
go-version: 1.24
check-latest: true
cache: true
- name: Get dependencies
-128
View File
@@ -17,141 +17,13 @@ below is kept current between tags and rolled into the next version when it ship
## [Unreleased]
### Added
- **Gemini streaming support** — the Gemini provider now supports streaming model responses. (`ai/gemini/`)
- **Model retry jitter controls** — model retry behavior can now use jitter controls to reduce synchronized retry bursts. (`ai/`, `agent/`)
- **Compacted memory summaries** — agent memory now exposes compacted run summaries for easier inspection and recovery. (`agent/`)
- **CLI input resume for agent runs** — the CLI can resume agent runs that require additional user input. (`cmd/micro/`, `agent/`)
### Changed
- **Remote agent chat streaming** — `micro chat` now streams replies from remote agents instead of waiting for the full response. (`cmd/micro/`, `agent/`)
### Fixed
- **Provider failure inspection metadata** — provider failures recorded during agent runs now retain classification metadata for inspection. (`agent/`, `ai/`)
### Security
- **x402 spend-cap hardening** — the paying `Client` now refuses a 402 whose `maxAmountRequired` is not a positive integer (a swallowed parse error or negative amount previously bypassed the budget cap), and a new `Config.RequireSettlement` fails closed when a paid request is served by a verify-only facilitator that never captures funds. (`wrapper/x402/`)
---
## [6.7.0] - July 2026
### Added
- **A2A streaming conformance harness** — A2A streaming behavior is now covered by focused conformance checks. (`gateway/a2a/`, `internal/harness/`)
- **Agent x402 spend budget guardrail** — agents now have spend budget guardrails for x402-paid tool calls. (`agent/`, `gateway/`)
- **First-agent chat/inspect fixture** — the maintained first-agent CLI fixture now covers chat and inspect boundaries together. (`internal/harness/`, `cmd/micro/`)
- **Zero-to-hero inspect transcript check** — the 0→hero harness now verifies the inspect transcript path stays visible in the lifecycle walkthrough. (`internal/harness/zero-to-hero-ci/`, `internal/website/docs/`)
### Changed
- **Agent stream run context propagation** — agent streams now preserve run context through streaming paths for more complete tracing and inspection. (`agent/`)
- **Postgres store pgx v5 migration** — the Postgres store now uses pgx v5. (`store/postgres/`, `go.mod`)
- **Plan-delegate plan persistence** — plan/delegate runs now persist plan state more defensively across harness scenarios. (`agent/`, `internal/harness/`)
### Fixed
- **Nested tool-call markup rejection** — agent argument parsing now rejects nested tool-call markup instead of accepting ambiguous tool input. (`agent/`)
- **Retry cancellation during backoff** — retry backoff now respects cancellation more reliably. (`agent/`, `ai/`)
- **Plan-delegate mock recovery regression gate** — the harness now catches plan/delegate mock recovery regressions before they ship. (`internal/harness/`, `agent/`)
- **First-agent fixture registration wait** — first-agent fixture registration is less race-prone during harness runs. (`internal/harness/`)
- **Memory stream Nack ordering** — memory stream Nack handling now preserves ordering more reliably. (`broker/memory/`)
- **Zero-to-hero fixture output race** — 0→hero fixture output is less race-prone during harness runs. (`internal/harness/zero-to-hero-ci/`)
### Documentation
- **First-agent quickcheck wayfinding** — public docs now keep the quickcheck path discoverable from the first-agent route. (`README.md`, `internal/website/docs/`)
- **Ordered 0→hero transcript** — docs and harness checks now keep the 0→hero transcript order explicit. (`internal/website/docs/`, `internal/harness/`)
- **First-agent debug breadcrumbs** — docs now surface the first-agent debug smoke path more clearly. (`internal/website/docs/`)
- **README badge cleanup** — the README no longer shows the Go Report Card badge. (`README.md`)
---
## [6.6.0] - July 2026
### Added
- **First-agent guide chain contract** — the harness now verifies the install → demo → examples → 0→hero guide chain stays connected for new agent builders. (`internal/harness/`, `internal/website/docs/`)
- **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/`)
- **First-agent quickcheck breadcrumbs** — first-agent docs now surface quickcheck wayfinding for install, scaffold, chat, inspect, and recovery paths. (`internal/website/docs/`, `README.md`)
- **First-agent chat wayfinding verification** — the harness now verifies first-agent chat wayfinding remains discoverable from the public docs route. (`internal/harness/`, `internal/website/docs/`)
### Changed
- **Universe A2A reachability probe** — the universe harness now exercises A2A reachability more defensively. (`internal/harness/`)
- **AtlasCloud workspace repair fallback** — AtlasCloud fallback handling now recovers workspace-repair tool calls more reliably. (`ai/atlascloud/`, `agent/`)
- **AtlasCloud empty-argument tool repair** — AtlasCloud text tool-call repair now handles empty-argument calls more consistently. (`ai/atlascloud/`, `agent/`)
### Removed
- **`go-micro.dev/v6/ai/flow`** — the alias-only backward-compatibility shim is removed; import the canonical [`go-micro.dev/v6/flow`](flow) instead (same types and functions). It had no internal callers. (`ai/flow/`)
### Fixed
- **A2A fallback artifact text** — A2A fallback responses now avoid leaking provider artifact text into agent-visible output. (`gateway/a2a/`, `agent/`)
- **Launch readiness notification replays** — launch-readiness notification replay paths now deduplicate repeated side effects. (`agent/`, `internal/harness/`)
- **Plan-delegate harness cleanup** — plan/delegate harness cleanup is more reliable after conformance runs. (`internal/harness/`)
- **AtlasCloud spoken notify replays** — AtlasCloud fallback handling now collapses spoken notification replays more consistently. (`ai/atlascloud/`, `agent/`)
- **Agent-flow onboarding side effects** — onboarding side-effect checks are more stable across the agent-flow harness. (`agent/`, `internal/harness/`)
- **Plan-delegate plan-only side effects** — plan/delegate recovery now preserves plan-only side effects more reliably. (`agent/`, `internal/harness/`)
- **Checkpointed tool result recording** — checkpoint resume paths now guard tool-result recording against duplicate or stale writes. (`agent/`)
- **Agent timeout notification completion** — universe runs now finalize observed notifications more reliably after agent timeouts. (`agent/`, `internal/harness/`)
- **Completed plan-delegate side effects** — completed plan/delegate side effects are accepted more consistently in recovery paths. (`agent/`, `internal/harness/`)
- **Agent-flow onboarding notifications** — agent-flow onboarding notification recovery is more reliable across replay scenarios. (`agent/`, `internal/harness/`)
### Documentation
- **Agent-agnostic mention model** — loop docs now describe the mention-driven agent model without binding it to one coding agent. (`internal/docs/`, `.github/loop/`)
- **First-agent quickcheck docs** — public docs now surface the first-agent quickcheck path for faster troubleshooting. (`internal/website/docs/`)
- **Agent resume breadcrumbs** — docs now add clearer resume breadcrumbs for checkpointed agent runs. (`internal/website/docs/`)
### Security
- **Govulncheck vulnerability gate** — CI now includes a govulncheck gate and wires vulnerability failures into loop triage. (`.github/workflows/`, `cmd/micro/loop/`)
- **Dependency vulnerability patches** — toolchain and dependency updates patch reachable CVEs across the project. (`go.mod`, `go.sum`)
---
## [6.5.0] - July 2026
### Added
- **Agent stream provider conformance** — provider conformance now covers agent streaming behavior so streaming-capable providers stay aligned with the harness contract. (`agent/`, `internal/harness/`)
- **First-agent docs CLI parity check** — the harness now verifies first-agent docs commands match the CLI wayfinding surface. (`internal/harness/`, `internal/website/docs/`)
- **Focused CLI inner-loop contract** — the local harness now covers scaffold, run/chat/inspect, and deploy dry-run boundaries in one first-run contract. (`internal/harness/`)
- **First-agent wayfinding breadcrumbs** — first-agent docs and examples now have locked breadcrumb coverage from the README through the runnable examples. (`README.md`, `internal/website/docs/`, `examples/`)
- **Offline `micro new` contract** — project scaffolding now has an offline contract so the first service path stays runnable without network access. (`cmd/micro/`, `internal/harness/`)
### Changed
- **Provider model call timeouts** — model call timeout enforcement now wraps provider calls more defensively, reducing hangs in agent and harness paths. (`agent/`, `ai/`)
- **First-agent harness diagnostics** — getting-started harness logs now make first-run and 0→hero failures easier to locate. (`internal/harness/`)
- **MiniMax streaming conformance** — MiniMax streaming coverage now exercises broader provider conformance behavior. (`ai/minimax/`, `internal/harness/`)
- **AtlasCloud streaming tool capability** — AtlasCloud tool-streaming capability detection is now aligned with provider fallback behavior. (`ai/atlascloud/`, `agent/`)
### Fixed
- **Partial text tool calls** — text tool-call recovery now repairs partial function-style calls more reliably before fallback parsing continues. (`agent/`)
- **Retry timeout test stability** — retry timeout coverage is less race-prone. (`agent/`)
- **Checkpointed tool-call resume** — resumed agent runs now preserve checkpointed tool calls across startup resume paths. (`agent/`)
- **Model retry backoff contracts** — retry backoff behavior now has focused contract coverage for model-call failures. (`agent/`, `ai/`)
- **AtlasCloud conformance markers** — AtlasCloud fallback paths now preserve conformance markers through tool-call recovery. (`ai/atlascloud/`, `agent/`)
- **AtlasCloud delegate text fallback** — delegate text fallback recovery is more reliable for AtlasCloud responses. (`ai/atlascloud/`, `agent/`)
- **AtlasCloud incomplete plan repairs** — incomplete plan repair paths now recover more consistently in AtlasCloud fallback handling. (`ai/atlascloud/`, `agent/`)
- **AtlasCloud partial text tool calls** — AtlasCloud fallback handling now repairs partial text-rendered tool calls more reliably. (`ai/atlascloud/`, `agent/`)
### Documentation
- **Roadmap agent status** — public roadmap docs now reflect the current agent lifecycle status more consistently. (`internal/website/docs/`)
- **Agent resume limits** — docs now describe checkpoint resume boundaries for agent runs. (`internal/website/docs/`)
- **Zero-to-hero harness boundaries** — docs now clarify which 0→hero lifecycle checks are maintained by the local harness. (`internal/website/docs/`, `internal/harness/`)
- **First-agent wayfinding guard** — first-agent docs wayfinding now has tighter guard coverage around the README, docs, and examples chain. (`README.md`, `internal/website/docs/`)
---
## [6.4.0] - July 2026
### 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/`)
- **Zero-to-hero deploy dry-run verification** — the maintained 0→hero harness now covers deploy dry-run boundaries for the services → agents → workflows lifecycle. (`internal/harness/`)
- **First-agent CLI wayfinding verification** — the harness now checks that first-agent CLI wayfinding stays discoverable. (`internal/harness/`)
- **Agent startup resume verification** — agent startup resume now has focused checkpoint coverage. (`agent/`, `internal/harness/`)
- **Direct first-agent chat prompts** — first-agent flows can accept direct chat prompts, reducing friction in the first useful conversation. (`cmd/micro/`, `agent/`)
- **Workflow run info on tool spans** — agent tool spans now include workflow run details for easier trace correlation. (`agent/`, `flow/`)
### 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/`)
- **Onboarding side-effect enforcement** — the agent-flow harness now fails when required onboarding side effects are missing, making lifecycle regressions visible. (`internal/harness/`)
- **Plan/delegate notify stability** — notify recovery is more deterministic across retry and replay paths. (`agent/`, `internal/harness/`)
- **AtlasCloud MiniMax tool fallback** — AtlasCloud MiniMax service-tool fallback now handles 400 responses and follow-up retries more reliably. (`ai/atlascloud/`, `agent/`)
### 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/`)
+5 -24
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 zero-to-hero-transcript inner-loop 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
.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,10 +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 zero-to-hero-transcript - Verify the ordered 0→hero lifecycle transcript"
@echo " make inner-loop - Verify scaffold → run/chat/inspect → deploy dry-run contract"
@echo " make cli-wayfinding - Verify installed first-agent CLI wayfinding commands"
@echo " make docs-wayfinding - Verify first-agent docs/CLI wayfinding stays in sync"
@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"
@@ -54,27 +52,11 @@ test-coverage:
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) cli-wayfinding
$(MAKE) inner-loop
$(MAKE) zero-to-hero-transcript
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 maintained 0→hero transcript in the same order documented for new
# developers: scaffold → run/chat/inspect → support-agent chat → flow history →
# deploy dry-run. This is the focused CI contract for the full lifecycle path.
zero-to-hero-transcript:
./internal/harness/zero-to-hero-ci/run.sh
# Focused provider-free CLI inner-loop contract: scaffold a service, keep the
# run/chat/inspect commands discoverable, and prove deploy dry-run reaches the
# documented boundary without remote side effects. Use this when README/docs/CLI
# drift is the concern and the full runtime harness is more than you need.
inner-loop:
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
go test ./internal/harness/zero-to-hero-ci -run 'TestZeroToHeroDeployDryRunCommandSmoke|TestNoSecretFirstAgentDebuggingSmoke|TestYourFirstAgentTutorialSmoke' -count=1
# 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.
@@ -87,8 +69,7 @@ cli-wayfinding:
# 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 'TestFirstAgentWayfinding' -count=1
go test ./cmd/micro -run 'TestFirstAgentDocsMatchCLIOutput|TestFirstAgentWalkthroughCLIBoundaries' -count=1
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.
+13 -29
View File
@@ -1,4 +1,4 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
Go Micro is an **agent harness** and service framework for Go.
@@ -78,19 +78,8 @@ access or provider keys, use:
make install-smoke
```
To verify the focused CLI inner-loop contract — scaffold → run/chat/inspect → deploy dry-run — use:
```bash
make inner-loop
```
To run only the ordered [0→hero services → agents → workflows transcript](internal/website/docs/guides/zero-to-hero.md) that CI guards, use:
```bash
make zero-to-hero-transcript
```
To run the broader local contract (including that transcript, chat/inspect CLI boundaries, and deploy dry-run), use:
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
@@ -102,24 +91,19 @@ After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Install troubleshooting](internal/website/docs/guides/install-troubleshooting.md) — verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path before agent work.
Run `make docs-wayfinding` to verify the focused no-secret docs/CLI contract that keeps these README and website commands aligned with the installed CLI.
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
3. `micro agent quickcheck` (or `micro agent debug`) — when scaffold → run → chat → inspect stalls, print the short recovery map before you dive into the full debugging guide.
4. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
5. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
6. [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.
7. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
8. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
5. [Examples wayfinding index](examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](examples/support/), and next interop examples from one map.
6. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
7. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
9. [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`.
10. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro agent preflight` before `micro run`, `micro agent doctor` after `micro run`,
then `micro chat` and `micro inspect agent <name>` to recover run history, memory,
and provider checks when the first conversation does something unexpected.
11. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
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.
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.
+30 -41
View File
@@ -32,55 +32,44 @@ default.
and history, end to end.
5. Battle-tested: works across every provider, fails safely, observable.
The forward work is **net-new capability**, not more hardening. Maintenance
(conformance, resilience, DX polish) continues in the background (see *Ongoing*
below) — but it is not the roadmap. This capability work is.
## Now — hardening
## Now — capability
- **Cross-provider conformance** — the same agent scenario across all seven
providers, gated on keys, on a schedule.
- **Failure & resilience** — timeouts, rate limits, cancellation, deadline/context
propagation, retry/backoff.
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
- **Agents that pay (x402 buyer in the runtime).** The seller side ships (paid
tools via the `wrapper/x402` middleware) and the buyer `x402.Client` (a
budget-capped `Payer` that turns a `402` into pay-and-retry) exists — but an
agent can't yet *autonomously* pay for a paid tool. Wire the buyer into the
agent tool loop: a budget-capped `AgentPayer` so an agent that hits a
payment-required tool settles it within budget and retries, with the spend
gated (like `ApproveTool`) and observable in `RunInfo`/traces. This makes
go-micro a runtime for **autonomous agent commerce**. *(flagship — decomposed
into issues in the loop queue)*
- **AP2 mandate foundation** ([#3552](https://github.com/micro/go-micro/issues/3552))
— verifiable payment **mandates** (a Checkout Mandate and a Payment Mandate),
signed and attached over A2A, with the Payment Mandate naming an x402 rail. The
authorization/audit layer above A2A + x402 that positions go-micro early in the
emerging agent-payments standard (Google's AP2, standardized via FIDO).
Additive and opt-in.
## Shipped agent depth
## Next — reach & deployment
- **Durable agent loop** — opt-in `Checkpoint` support lets agent `Ask` and
streaming runs persist, list pending work, and resume without replaying completed
tool calls. Human-input pauses resume through explicit input helpers.
- **Agent observability** — agent `RunInfo` now feeds OpenTelemetry spans/events
across runs, model turns, tool calls, retries, delegation lineage, and resume
checkpoints.
- **gRPC-reflection MCP** — derive MCP tools from *any* gRPC service via server
reflection, not just go-micro-native handlers. Point the gateway at an external
gRPC service and its methods become agent tools — a large jump in what an agent
can operate.
- **Kubernetes operator + CRDs** — `Agent`, `Service`, and `Flow` as first-class
Kubernetes resources; an operator reconciles them into Deployments wired to the
registry. The production deployment story for teams already on K8s.
## Next — agentic depth
## Later — exploratory
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Resume operations polish** — keep improving CLI/docs breadcrumbs for finding
pending agent runs and deciding whether to call resume, resume-input, or stream
resume in production.
- **Observability hardening** — keep span attributes and run inspection coherent
across agents, flows, and gateways as more providers and workflow paths are
exercised.
- **Runtime-fitness loop** — a persistently-running dogfood app (Mu) plus an
operator/canary loop role, so the autonomous loop evolves go-micro against
**real runtime signal** (latency, errors, cost) with canary + rollback — not
just green CI. The demand signal the loop is missing today.
- **HTTP/3 transport**; richer A2A live-stream reconnection (`tasks/resubscribe`,
`input-required` handoffs); memory management (summarization, retrieval/RAG).
## Later
## Ongoing — hardening & DX (background, not the headline)
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
Continuous but **capped** so it never crowds out capability: cross-provider
conformance, failure/resilience (timeouts, cancellation, retry/backoff), the
0→1 and 0→hero getting-started contract, streaming/observability coherence, and a
seamless CLI inner loop (scaffold → run → chat → inspect → deploy). Real, but
maintenance — the loop should spend the majority of its cycles on the capability above,
not here.
## Developer experience (ongoing)
- A seamless CLI inner loop (scaffold → run → chat → inspect → deploy); UI
discipline (trim what isn't great); a maintained real-world example that doubles
as the 0→hero reference; docs kept in lockstep with the code.
## How it's sustained
+2 -71
View File
@@ -15,9 +15,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
@@ -35,7 +33,6 @@ import (
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
@@ -83,8 +80,6 @@ type agentImpl struct {
// steps counts tool executions in the current Ask, for MaxSteps.
steps int
// spend counts reserved paid-tool spend in the current Ask, for MaxSpend.
spend int64
// calls counts identical tool calls (name+args) in the current Ask,
// for LoopLimit.
calls map[string]int
@@ -110,11 +105,6 @@ type agentImpl struct {
// durable delegate-result cache is written.
delegateMu sync.Mutex
delegateCalls map[string]*delegateCall
// stopCh lets Stop unblock Run. Without this, tests and harnesses that
// start agents in goroutines can leave Run parked forever after the RPC
// server has been stopped.
stopCh chan struct{}
}
// New creates a new Agent.
@@ -230,9 +220,6 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
a.mu.Lock()
defer a.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if a.model == nil {
a.setup()
}
@@ -240,12 +227,6 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
runID := uuid.New().String()
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: runID,
ParentID: a.parentRunID,
Agent: a.opts.Name,
})
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{
@@ -257,44 +238,10 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
_ = stream.Close()
return nil, err
}
a.mem.Add("user", message)
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
// StreamChat serves the Agent.StreamChat RPC endpoint by forwarding stream-capable
// remote clients to the agent streaming path. If the model cannot stream, the
// underlying error is returned so callers can fall back to Agent.Chat.
func (a *agentImpl) StreamChat(ctx context.Context, stream pb.Agent_StreamChatStream) error {
req, err := stream.Recv()
if err != nil {
return err
}
aiStream, err := a.streamAskAI(ctx, req.Message)
if err != nil {
return err
}
defer aiStream.Close()
for {
chunk, err := aiStream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if chunk == nil || chunk.Reply == "" {
continue
}
if err := stream.Send(&pb.ChatResponse{Reply: chunk.Reply, Agent: a.opts.Name}); err != nil {
return err
}
}
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
// flow.Pending for startup recovery loops that drain durable agent work.
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
@@ -351,7 +298,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
a.mem.Add("user", message)
}
a.steps = 0
a.spend = 0
a.calls = map[string]int{}
a.pause = nil
@@ -400,12 +346,9 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
Jitter: a.opts.ModelRetryJitter,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
failureKind := ai.ClassifyError(err)
attempts := agentRunFailureAttempts(err)
err = agentOperationalError(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
@@ -414,9 +357,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Attempts = attempts
run.Steps[0].Error = err.Error()
run.Steps[0].ErrorKind = string(failureKind)
_ = a.saveRun(ctx, run)
return nil, err
}
@@ -598,11 +539,6 @@ func (a *agentImpl) Run() error {
return fmt.Errorf("failed to start agent: %w", err)
}
stopCh := make(chan struct{})
a.mu.Lock()
a.stopCh = stopCh
a.mu.Unlock()
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
// Optionally serve the agent directly over the A2A protocol, calling
@@ -624,17 +560,12 @@ func (a *agentImpl) Run() error {
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
}
<-stopCh
ch := make(chan struct{})
<-ch
return nil
}
func (a *agentImpl) Stop() error {
a.mu.Lock()
if a.stopCh != nil {
close(a.stopCh)
a.stopCh = nil
}
a.mu.Unlock()
if a.server != nil {
return a.server.Stop()
}
-10
View File
@@ -38,16 +38,6 @@ func TestNew(t *testing.T) {
}
}
func TestBundledProviderImportsIncludeMiniMaxForConformance(t *testing.T) {
if model := ai.New("minimax", ai.WithAPIKey("test-key")); model == nil {
t.Fatal("ai.New(\"minimax\") returned nil; agent live conformance cannot exercise MiniMax")
}
caps := ai.ProviderCapabilities("minimax")
if !caps.Stream || !caps.ToolStream {
t.Fatalf("MiniMax capabilities = %#v, want streaming and tool streaming registered", caps)
}
}
func TestChatResponseIncludesRunIDs(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return &ai.Response{Reply: "ok"}, nil
+5 -108
View File
@@ -5,8 +5,6 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -14,7 +12,6 @@ import (
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/store"
"go-micro.dev/v6/wrapper/x402"
)
// Built-in agent tools. These are not service endpoints — they are
@@ -131,11 +128,9 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.toolTimeoutWrap(h)
h = a.x402PayWrap(h)
h = a.toolRetryWrap(h)
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.spendWrap(h)
h = a.loopWrap(h)
h = a.stepWrap(h)
h = a.planWrap(h)
@@ -177,64 +172,6 @@ func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
}
}
// x402PayWrap pays an x402 Payment Required tool result and retries the
// underlying HTTP tool once. Tools that proxy HTTP paid resources can return the
// raw x402 402 challenge body and include a "url" input; the agent then uses
// wrapper/x402.Client so payer and budget semantics stay in one place.
func (a *agentImpl) x402PayWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
res := next(ctx, call)
if res.Refused != "" || !isX402Challenge(res.Content) {
return res
}
url, _ := call.Input["url"].(string)
if url == "" {
return errResult(call.ID, "x402: payment required but tool result did not include a retryable url input")
}
budget := a.opts.Budget
if budget > 0 {
remaining := budget - a.spend
if remaining <= 0 {
return refused(call.ID, ai.RefusedSpendBudget, fmt.Sprintf(
"x402 spend budget exceeded: no budget remaining for %s (spent %d of %d)",
call.Name, a.spend, budget))
}
budget = remaining
}
client := &x402.Client{Payer: a.opts.Payer, Budget: budget}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return errResult(call.ID, err.Error())
}
resp, err := client.Do(req)
if err != nil {
if strings.Contains(err.Error(), "would exceed budget") {
return refused(call.ID, ai.RefusedSpendBudget, err.Error())
}
return errResult(call.ID, err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return errResult(call.ID, err.Error())
}
a.spend += client.Spent()
var value any
if err := json.Unmarshal(body, &value); err != nil {
value = string(body)
}
return ai.ToolResult{ID: call.ID, Value: value, Content: string(body), Attempts: 2}
}
}
func isX402Challenge(content string) bool {
var ch struct {
X402Version int `json:"x402Version"`
Accepts []x402.Requirements `json:"accepts"`
}
return json.Unmarshal([]byte(content), &ch) == nil && ch.X402Version > 0 && len(ch.Accepts) > 0
}
// toolRetryWrap retries transient tool failures with bounded backoff. It is
// opt-in because tools can have side effects; guardrail refusals and caller
// cancellation are never retried.
@@ -359,9 +296,6 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
if call.Name == toolPlan {
return a.handlePlan(call)
}
if containsNestedTextToolCall(call.Input) {
return refused(call.ID, ai.RefusedApproval, "malformed tool call: nested text tool-call markup found inside arguments; call the intended tool directly with clean JSON arguments")
}
if call.Name == toolDelegate {
if blocked := a.unfinishedPlanStepsBeforeDelegation(); len(blocked) > 0 {
return refused(call.ID, ai.RefusedApproval, "complete these plan steps before delegating: "+strings.Join(blocked, ", "))
@@ -438,32 +372,6 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
}
}
// spendWrap reserves a per-run x402 spend budget before paid tool execution.
func (a *agentImpl) spendWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
amount := a.opts.ToolSpend[call.Name]
if amount <= 0 || a.opts.MaxSpend <= 0 {
return next(ctx, call)
}
if a.spend+amount > a.opts.MaxSpend {
return refused(call.ID, ai.RefusedSpendBudget, fmt.Sprintf(
"x402 spend budget exceeded: paying %d for %s would exceed per-run budget (spent %d of %d)",
amount, call.Name, a.spend, a.opts.MaxSpend))
}
a.spend += amount
if info, ok := ai.RunInfoFrom(ctx); ok {
info.Spent = a.spend
info.ToolSpend = amount
ctx = ai.WithRunInfo(ctx, info)
}
res := next(ctx, call)
if res.Refused != "" || toolErrorMessage(res) != "" {
a.spend -= amount
}
return res
}
}
// handlePlan persists the supplied plan to the agent's memory and
// echoes it back so the model can see the stored state.
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
@@ -829,28 +737,17 @@ func normalizeDelegateTask(task string) string {
}
}, task)
task = strings.Join(strings.Fields(task), " ")
if strings.Contains(task, "owner") &&
if strings.Contains(task, "notify") &&
strings.Contains(task, "owner") &&
strings.Contains(task, "acme") &&
isLaunchReadinessDelegateTask(task) {
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
}
func isLaunchReadinessDelegateTask(task string) bool {
hasNotify := strings.Contains(task, "notify") || strings.Contains(task, "notification") || strings.Contains(task, "tell")
hasLaunch := strings.Contains(task, "launch")
hasPlanOrReadiness := strings.Contains(task, "plan") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
hasCompletion := strings.Contains(task, "ready") ||
strings.Contains(task, "readiness") ||
strings.Contains(task, "prepared") ||
strings.Contains(task, "complete") ||
strings.Contains(task, "finished") ||
strings.Contains(task, "done") ||
strings.Contains(task, "sent")
return hasNotify && hasLaunch && hasPlanOrReadiness && hasCompletion
}
// isAgent reports whether name resolves to a registered agent (a
// service advertising type=agent in its metadata).
func (a *agentImpl) isAgent(name string) bool {
+9 -14
View File
@@ -196,21 +196,16 @@ func TestDelegateResultCacheReusesLaunchReadinessParaphrases(t *testing.T) {
t.Fatal("storeDelegateResult returned empty content")
}
replayedTasks := []string{
"Notify the plan owner at owner @ acme.com that launch readiness is prepared and complete.",
"Tell owner at acme dot com the launch readiness notification was sent and the plan is done.",
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")
}
for i, replayedTask := range replayedTasks {
cached, ok := a.cachedDelegateResult("delegate-replay", " COMMS ", replayedTask)
if !ok {
t.Fatalf("cachedDelegateResult missed equivalent launch-readiness delegate replay %d", i)
}
if cached.ID != "delegate-replay" {
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)
}
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)
}
}
+12 -25
View File
@@ -3,7 +3,6 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -216,14 +215,6 @@ func (e *operationalError) Unwrap() error {
return e.err
}
func agentRunFailureAttempts(err error) int {
var retryErr *ai.RetryError
if err != nil && errors.As(err, &retryErr) && retryErr.Attempts > 0 {
return retryErr.Attempts
}
return 1
}
func agentOperationalError(err error) error {
if err == nil {
return nil
@@ -244,32 +235,28 @@ func agentOperationalError(err error) error {
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
run := a.currentRun
if a.opts.Checkpoint == nil || run == nil {
if a.opts.Checkpoint == nil || a.currentRun == nil {
return next(ctx, call)
}
name := toolCheckpointName(call)
if rec, ok := findStep(run.Steps, name); ok && rec.Status == "done" {
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
}
idx := upsertStep(&run.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
_ = a.saveRun(ctx, *run)
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
_ = a.saveRun(ctx, *a.currentRun)
res := next(ctx, call)
if idx < 0 || idx >= len(run.Steps) || run.Steps[idx].Name != name {
idx = upsertStep(&run.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
}
run.Steps[idx].Attempts++
a.currentRun.Steps[idx].Attempts++
if res.Refused != "" {
run.Steps[idx].Status = "failed"
run.Steps[idx].Error = res.Content
_ = a.saveRun(ctx, *run)
a.currentRun.Steps[idx].Status = "failed"
a.currentRun.Steps[idx].Error = res.Content
_ = a.saveRun(ctx, *a.currentRun)
return res
}
run.Steps[idx].Status = "done"
run.Steps[idx].Result = res.Content
run.Steps[idx].Error = ""
_ = a.saveRun(ctx, *run)
a.currentRun.Steps[idx].Status = "done"
a.currentRun.Steps[idx].Result = res.Content
a.currentRun.Steps[idx].Error = ""
_ = a.saveRun(ctx, *a.currentRun)
return res
}
}
-39
View File
@@ -154,45 +154,6 @@ func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
}
}
func TestCheckpointToolWrapSurvivesClearedCurrentRun(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-cleared-run-agent")
run := flow.Run{
ID: "run-1",
Flow: "tool-cleared-run-agent",
Status: "running",
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
}
a := &agentImpl{
opts: newOptions(Name("tool-cleared-run-agent"), WithCheckpoint(cp)),
currentRun: &run,
}
handler := a.checkpointToolWrap(func(context.Context, ai.ToolCall) ai.ToolResult {
a.currentRun = nil
return ai.ToolResult{ID: "call-1", Content: "created"}
})
res := handler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
if res.Content != "created" {
t.Fatalf("tool result = %q, want created", res.Content)
}
loaded, ok, err := cp.Load(ctx, "run-1")
if err != nil {
t.Fatalf("load checkpoint: %v", err)
}
if !ok {
t.Fatal("checkpoint missing")
}
rec, ok := findStep(loaded.Steps, `tool:external.create:{"title":"Design"}`)
if !ok {
t.Fatalf("checkpoint steps = %#v, want completed tool step", loaded.Steps)
}
if rec.Status != "done" || rec.Result != "created" || rec.Attempts != 1 {
t.Fatalf("tool checkpoint = %#v, want done result with one attempt", rec)
}
}
func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
+21 -59
View File
@@ -22,20 +22,17 @@ type conformanceProvider struct {
live bool
}
var agentConformanceProviders = []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "minimax", key: "MINIMAX_API_KEY", model: "GO_MICRO_CONFORMANCE_MINIMAX_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
func TestAgentProviderConformanceMatrix(t *testing.T) {
providers := agentConformanceProviders
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
@@ -50,7 +47,16 @@ func TestAgentProviderConformanceMatrix(t *testing.T) {
}
func TestAgentProviderStreamConformanceMatrix(t *testing.T) {
providers := streamConformanceProviders()
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "minimax", key: "MINIMAX_API_KEY", model: "GO_MICRO_CONFORMANCE_MINIMAX_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
@@ -64,46 +70,6 @@ func TestAgentProviderStreamConformanceMatrix(t *testing.T) {
}
}
func streamConformanceProviders() []conformanceProvider {
providers := make([]conformanceProvider, 0, len(agentConformanceProviders))
for _, provider := range agentConformanceProviders {
// Gemini is covered by the non-streaming agent/tool matrix, but does not
// currently advertise streaming in the provider capability registry.
if provider.name == "gemini" {
continue
}
providers = append(providers, provider)
}
return providers
}
func TestAgentProviderConformanceMatrixIncludesEveryLiveProvider(t *testing.T) {
want := map[string]string{
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
"gemini": "GEMINI_API_KEY",
"groq": "GROQ_API_KEY",
"minimax": "MINIMAX_API_KEY",
"mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY",
}
got := map[string]string{}
for _, provider := range agentConformanceProviders {
if provider.live {
got[provider.name] = provider.key
}
}
for name, key := range want {
if got[name] != key {
t.Fatalf("agentConformanceProviders[%q] key = %q, want %q", name, got[name], key)
}
}
if len(got) != len(want) {
t.Fatalf("agentConformanceProviders live providers = %#v, want exactly %#v", got, want)
}
}
func runAgentStreamConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
@@ -113,13 +79,9 @@ func runAgentStreamConformanceScenario(t *testing.T, provider conformanceProvide
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s stream conformance", provider.name)
}
caps := ai.ProviderCapabilities(provider.name)
if !caps.Stream {
if caps := ai.ProviderCapabilities(provider.name); !caps.Stream {
t.Fatalf("ProviderCapabilities(%q).Stream = false, want true for stream conformance", provider.name)
}
if !caps.ToolStream {
t.Skipf("ProviderCapabilities(%q).ToolStream = false; skipping live tool stream conformance", provider.name)
}
} else {
var sawToolSchema bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
-225
View File
@@ -2,17 +2,12 @@ package agent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go-micro.dev/v6/wrapper/x402"
)
// toolContent runs a tool call through a handler and returns the content
@@ -95,223 +90,3 @@ func TestApproveToolDoesNotGatePlan(t *testing.T) {
t.Error("plan should have been persisted despite the denying approver")
}
}
func TestMaxSpendAllowsPaidToolWithinBudget(t *testing.T) {
calls := 0
a := newTestAgent(Name("paid-within-budget"),
MaxSpend(10),
ToolSpend("paid.lookup", 7),
WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) {
calls++
return `{"ok":true}`, nil
}),
)
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}})
if calls != 1 {
t.Fatalf("paid tool was not executed")
}
if res.Refused != "" {
t.Fatalf("paid tool was refused: %+v", res)
}
if res.Content != `{"ok":true}` {
t.Fatalf("content = %q, want paid result", res.Content)
}
}
func TestMaxSpendRefusesPaidToolBeforePaymentWhenBudgetExceeded(t *testing.T) {
calls := 0
a := newTestAgent(Name("paid-over-budget"),
MaxSpend(5),
ToolSpend("paid.lookup", 7),
WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) {
calls++
return `{"ok":true}`, nil
}),
)
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}})
if calls != 0 {
t.Fatalf("paid tool ran despite budget refusal")
}
if res.Refused != ai.RefusedSpendBudget {
t.Fatalf("Refused = %q, want %q (result %+v)", res.Refused, ai.RefusedSpendBudget, res)
}
if !strings.Contains(res.Content, "x402 spend budget exceeded") {
t.Fatalf("content = %q, want inspectable budget refusal", res.Content)
}
}
func TestMaxSpendRollsBackFailedPaidToolReservation(t *testing.T) {
calls := 0
a := newTestAgent(Name("paid-rollback"),
MaxSpend(10),
ToolSpend("paid.lookup", 7),
WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) {
calls++
if calls == 1 {
return "", context.Canceled
}
return `{"ok":true}`, nil
}),
)
h := a.toolHandler()
first := h(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}})
if first.Refused != "" || !strings.Contains(first.Content, "context canceled") {
t.Fatalf("first result = %+v, want tool error without guardrail refusal", first)
}
second := h(context.Background(), ai.ToolCall{ID: "paid-2", Name: "paid.lookup", Input: map[string]any{}})
if second.Refused != "" || second.Content != `{"ok":true}` {
t.Fatalf("second result = %+v, want reservation rollback to allow retry", second)
}
}
func TestNestedTextToolCallArgumentsAreRefused(t *testing.T) {
called := false
a := newTestAgent(Name("nested-tool-arg"),
WithTool("task.add", "add task", nil, func(context.Context, map[string]any) (string, error) {
called = true
return "created", nil
}),
)
content := toolContent(a.toolHandler(), "task.add", map[string]any{
"title": `Continue the launch plan. <tool_call name="plan">{"steps":[{"task":"Design","status":"pending"}]}</tool_call>`,
})
if called {
t.Fatal("tool handler ran despite nested text tool-call markup in arguments")
}
if !strings.Contains(content, "nested text tool-call markup") {
t.Fatalf("content = %q, want nested tool-call refusal", content)
}
}
type agentMockPayer struct{ calls int }
func (p *agentMockPayer) Pay(ctx context.Context, req x402.Requirements) (string, error) {
p.calls++
return "paid", nil
}
func TestAgentPayerPaysX402ToolResultAndRetries(t *testing.T) {
paid := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(x402.PaymentHeader) == "paid" {
paid = true
_, _ = w.Write([]byte(`{"ok":true}`))
return
}
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]any{
"x402Version": x402.Version,
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}},
})
}))
defer srv.Close()
payer := &agentMockPayer{}
st := store.NewMemoryStore()
a := newTestAgent(Name("x402-payer"), WithStore(st), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}))
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "run-paid", Agent: "x402-payer"})
res := a.toolHandler()(ctx, ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
if !paid || payer.calls != 1 {
t.Fatalf("payment not made: paid=%v payer.calls=%d", paid, payer.calls)
}
if res.Content != `{"ok":true}` || res.Attempts != 2 {
t.Fatalf("result = %+v, want paid response with retry attempt", res)
}
events, err := LoadRunEvents(st, "x402-payer", "run-paid")
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Spent != 7 || events[0].ToolSpend != 7 {
t.Fatalf("spend events = %#v, want one tool event with spent/tool_spend 7", events)
}
}
func TestAgentPayerRefusesX402OverBudget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]any{
"x402Version": x402.Version,
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "70", Resource: r.URL.String(), PayTo: "0xmerchant"}},
})
}))
defer srv.Close()
payer := &agentMockPayer{}
a := newTestAgent(Name("x402-over-budget"), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}))
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
if payer.calls != 0 {
t.Fatalf("payer called despite over-budget refusal")
}
if res.Refused != ai.RefusedSpendBudget || !strings.Contains(res.Content, "would exceed budget") {
t.Fatalf("result = %+v, want budget refusal", res)
}
}
func TestAgentPayerRequiredWithoutPayerReturnsClearError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]any{
"x402Version": x402.Version,
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}},
})
}))
defer srv.Close()
a := newTestAgent(Name("x402-no-payer"), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}))
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
if !strings.Contains(res.Content, "no Payer configured") {
t.Fatalf("content = %q, want no payer error", res.Content)
}
}
-10
View File
@@ -68,8 +68,6 @@ func init() {
_ = m.Init(opts...)
return m
})
ai.RegisterStream("fake")
ai.RegisterToolStream("fake")
}
// fakeClient embeds the default client (so NewRequest works) and
@@ -248,12 +246,4 @@ func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
if !sawRecall {
t.Error("model request did not recall archived matching context")
}
summary := Summary(a.mem)
if !strings.Contains(summary, "Conversation memory summary") || !strings.Contains(summary, "alpha") {
t.Fatalf("inspectable memory summary = %q, want compacted alpha summary", summary)
}
a.mem.Clear()
if summary := Summary(a.mem); summary != "" {
t.Fatalf("summary after Clear = %q, want empty", summary)
}
}
-52
View File
@@ -46,27 +46,6 @@ type MemoryRecall interface {
Recall(query string, limit int) []ai.Message
}
// MemorySummary is implemented by memory backends that expose their current
// compacted summary for inspection. It lets long-running agents make memory
// compaction observable without coupling callers to a concrete store.
type MemorySummary interface {
Summary() string
}
// Summary returns the current compacted-memory summary for m, when supported.
// It returns an empty string for memory backends that have not compacted or do
// not expose an inspectable summary.
func Summary(m Memory) string {
if m == nil {
return ""
}
summarizer, ok := m.(MemorySummary)
if !ok {
return ""
}
return summarizer.Summary()
}
// NewMemory returns the default store-backed memory: an in-process
// conversation buffer (truncated to limit) that persists to the store
// under key, so an agent picks up where it left off after a restart.
@@ -137,7 +116,6 @@ type storeMemory struct {
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
summary string
retrieveAll bool
}
@@ -162,20 +140,10 @@ func (m *storeMemory) Clear() {
m.mu.Lock()
m.hist.Reset()
m.archive = nil
m.summary = ""
m.mu.Unlock()
m.save()
}
// Summary returns the latest compacted summary text, if this memory has
// compacted older turns. The returned value is safe to show in debug UIs or
// checkpoints because it is exactly the summary retained in active context.
func (m *storeMemory) Summary() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.summary
}
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
@@ -234,16 +202,12 @@ func (m *storeMemory) load() {
}
m.mu.Lock()
m.archive = state.Archive
m.summary = state.Summary
if m.retrieveAll && len(m.archive) == 0 {
m.archive = append(m.archive, state.Messages...)
}
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
if m.summary == "" {
m.summary = currentMemorySummary(state.Messages)
}
m.mu.Unlock()
}
@@ -255,7 +219,6 @@ func (m *storeMemory) save() {
data, err := json.Marshal(memoryState{
Messages: m.hist.Messages(),
Archive: m.archive,
Summary: m.summary,
})
m.mu.Unlock()
if err != nil {
@@ -293,7 +256,6 @@ func (m *storeMemory) compact() {
if summary.Role == "" {
summary.Role = "system"
}
m.summary = fmt.Sprint(summary.Content)
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
for _, msg := range recent {
@@ -301,19 +263,6 @@ func (m *storeMemory) compact() {
}
}
func currentMemorySummary(msgs []ai.Message) string {
for _, msg := range msgs {
if msg.Role != "system" {
continue
}
text := fmt.Sprint(msg.Content)
if strings.HasPrefix(text, "Conversation memory summary:") {
return text
}
}
return ""
}
func defaultMemorySummary(msgs []ai.Message) ai.Message {
return ai.Message{
Role: "system",
@@ -368,5 +317,4 @@ func recallTerms(query string) []string {
type memoryState struct {
Messages []ai.Message `json:"messages"`
Archive []ai.Message `json:"archive,omitempty"`
Summary string `json:"summary,omitempty"`
}
-12
View File
@@ -132,14 +132,8 @@ func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
if summary := Summary(m); !strings.Contains(summary, "alpha budget is 42") {
t.Fatalf("inspectable summary = %q, want alpha budget", summary)
}
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
if summary := Summary(reloaded); !strings.Contains(summary, "alpha budget is 42") {
t.Fatalf("reloaded summary = %q, want alpha budget", summary)
}
recall, ok := reloaded.(MemoryRecall)
if !ok {
t.Fatal("compacting memory should support recall")
@@ -171,14 +165,8 @@ func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
}
if summary := Summary(m); summary != "custom summary count=3" {
t.Fatalf("inspectable custom summary = %q, want custom summary count=3", summary)
}
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
if summary := Summary(reloaded); summary != "custom summary count=3" {
t.Fatalf("reloaded custom summary = %q, want custom summary count=3", summary)
}
recall := reloaded.(MemoryRecall)
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
-49
View File
@@ -10,7 +10,6 @@ import (
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go-micro.dev/v6/wrapper/x402"
"go.opentelemetry.io/otel/trace"
)
@@ -60,9 +59,6 @@ type Options struct {
// ModelRetryBackoff is the base delay between transient provider failures
// (grows exponentially per attempt when retries are enabled).
ModelRetryBackoff time.Duration
// ModelRetryJitter adds up to this random delay to each provider retry
// backoff. Default 0 preserves deterministic timing unless explicitly set.
ModelRetryJitter time.Duration
// ToolTimeout bounds each tool execution (0 disables). The timeout is
// applied before custom tools, delegate, and service RPC calls so context
// deadlines propagate consistently through the agent loop.
@@ -102,14 +98,6 @@ type Options struct {
LoopLimit int
// Approve gates each action before it runs. Nil = allow all.
Approve ApproveFunc
// MaxSpend bounds paid x402 tool spend per Ask in the asset's smallest
// unit (0 = disabled). ToolSpend lists known paid tools and their prices.
MaxSpend int64
ToolSpend map[string]int64
// Payer lets the agent settle x402 Payment Required challenges from tools.
// Budget bounds autonomous x402 payments per Ask (0 = unlimited).
Payer x402.Payer
Budget int64
// A2AAddress, if set, makes Run serve this agent over the A2A protocol
// on that address directly (no separate gateway), e.g. ":4000".
@@ -236,37 +224,6 @@ func ApproveTool(fn ApproveFunc) Option {
return func(o *Options) { o.Approve = fn }
}
// MaxSpend bounds paid x402 tool spend per Ask, in the asset's smallest unit
// (0 = disabled). A paid tool that would exceed the cap is refused before the
// tool handler runs or any payment can be made.
func MaxSpend(amount int64) Option {
return func(o *Options) { o.MaxSpend = amount }
}
// ToolSpend records the x402 price for a tool, in the asset's smallest unit,
// so MaxSpend can reserve budget before execution. Non-positive amounts are
// treated as free.
func ToolSpend(tool string, amount int64) Option {
return func(o *Options) {
if o.ToolSpend == nil {
o.ToolSpend = map[string]int64{}
}
o.ToolSpend[tool] = amount
}
}
// Payer configures the wallet/signing hook used to settle x402-paid tools.
// Without a payer, payment-required tool results are returned as clear errors.
func Payer(p x402.Payer) Option {
return func(o *Options) { o.Payer = p }
}
// Budget bounds autonomous x402 payments per Ask, in the asset's smallest
// unit (0 = unlimited). The budget is enforced by wrapper/x402.Client.
func Budget(amount int64) Option {
return func(o *Options) { o.Budget = amount }
}
// LoopLimit sets how many times the agent may repeat the same tool call
// (same name and arguments) in one Ask before it is refused as a
// no-progress loop. 0 disables loop detection.
@@ -295,12 +252,6 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
}
}
// ModelRetryJitter adds bounded random jitter to provider retry backoff.
// Set 0 to disable.
func ModelRetryJitter(d time.Duration) Option {
return func(o *Options) { o.ModelRetryJitter = d }
}
// ToolRetry sets the tool retry budget and backoff for transient failures.
// Attempts include the first call. Retries are opt-in because tools may have
// side effects; keep handlers idempotent before enabling this.
+3 -37
View File
@@ -51,8 +51,6 @@ const (
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
AttrRunEventKind = "agent.event.kind"
AttrSpend = "agent.spend"
AttrToolSpend = "agent.tool.spend"
)
type RunEvent struct {
@@ -75,8 +73,6 @@ type RunEvent struct {
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
InputChars int `json:"input_chars,omitempty"`
Spent int64 `json:"spent,omitempty"`
ToolSpend int64 `json:"tool_spend,omitempty"`
}
type Usage = ai.Usage
@@ -86,8 +82,7 @@ type Usage = ai.Usage
type RunListOptions struct {
// Status, when set, keeps only runs with the matching status
// (for example "running", "done", "canceled", "timeout",
// "rate_limited", "auth", "configuration", "unavailable",
// "provider_error", "error", or "refused").
// "rate_limited", "error", or "refused").
Status string
// TraceID, when set, keeps only runs correlated with this trace id.
// A prefix is accepted so operators can paste the shortened trace id
@@ -115,7 +110,6 @@ type RunSummary struct {
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
Spent int64 `json:"spent,omitempty"`
}
func (a *agentImpl) tracer() trace.Tracer {
@@ -367,7 +361,6 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
spentBefore := a.spend
if a.opts.TraceProvider == nil {
res := next(ctx, call)
@@ -377,7 +370,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
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), Spent: a.spend, ToolSpend: a.spend - spentBefore})
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
}
@@ -391,7 +384,6 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(spanAttrs...))
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
toolSpend := a.spend - spentBefore
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
toolAttempts := res.Attempts
if toolAttempts <= 0 {
@@ -401,9 +393,6 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
if a.opts.ToolMaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, a.opts.ToolMaxAttempts))
}
if toolSpend > 0 {
attrs = append(attrs, attribute.Int64(AttrSpend, a.spend), attribute.Int64(AttrToolSpend, toolSpend))
}
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
@@ -419,7 +408,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
} else {
span.SetStatus(codes.Ok, "")
}
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr), Spent: a.spend, ToolSpend: toolSpend})
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
}
@@ -506,12 +495,6 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
if e.InputChars > 0 {
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
}
if e.Spent > 0 {
attrs = append(attrs, attribute.Int64(AttrSpend, e.Spent))
}
if e.ToolSpend > 0 {
attrs = append(attrs, attribute.Int64(AttrToolSpend, e.ToolSpend))
}
attrs = appendUsage(attrs, e.Tokens)
if e.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
@@ -546,12 +529,6 @@ func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attr
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
}
if info.Spent > 0 {
attrs = append(attrs, attribute.Int64(AttrSpend, info.Spent))
}
if info.ToolSpend > 0 {
attrs = append(attrs, attribute.Int64(AttrToolSpend, info.ToolSpend))
}
return attrs
}
@@ -638,9 +615,6 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
if e.ErrorKind != "" {
summary.LastErrorKind = e.ErrorKind
}
if e.Spent > summary.Spent {
summary.Spent = e.Spent
}
}
if opts.Status != "" && summary.Status != opts.Status {
continue
@@ -688,14 +662,6 @@ func runErrorStatus(kind string) string {
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
case ai.ErrorKindAuth:
return "auth"
case ai.ErrorKindConfiguration:
return "configuration"
case ai.ErrorKindUnavailable:
return "unavailable"
case ai.ErrorKindProvider:
return "provider_error"
default:
return "error"
}
+1 -61
View File
@@ -421,63 +421,6 @@ func spanAttributes(attrs []attribute.KeyValue) map[string]string {
return out
}
func TestAgentOpenTelemetryToolSpanIncludesSpend(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("spender"), Provider("oteltest"), Model("unit-model"), WithStore(st), TraceProvider(tp), MaxSpend(10), ToolSpend("probe", 7), WithTool("probe", "probe", nil, func(ctx context.Context, input map[string]any) (string, error) {
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from paid tool context")
}
if info.Spent != 7 || info.ToolSpend != 7 {
t.Fatalf("RunInfo spend = (%d, %d), want (7, 7)", info.Spent, info.ToolSpend)
}
return "ok", nil
}))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
var sawToolSpan bool
for _, s := range exp.GetSpans().Snapshots() {
if s.Name() != spanNameToolCall {
continue
}
sawToolSpan = true
attrs := spanAttributes(s.Attributes())
if attrs[AttrSpend] != "7" || attrs[AttrToolSpend] != "7" {
t.Fatalf("tool span missing spend attributes: %#v", attrs)
}
if !spanEventHasAttribute(s.Events(), "agent.tool", AttrToolSpend, "7") {
t.Fatalf("tool event missing spend attribute: %#v", s.Events())
}
}
if !sawToolSpan {
t.Fatal("tool span not emitted")
}
summaries, err := ListRunSummaries(st, "spender")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 || summaries[0].Spent != 7 {
t.Fatalf("summary spend = %#v, want 7", summaries)
}
}
func spanEventHasAttribute(events []trace.Event, name, key, value string) bool {
for _, e := range events {
if e.Name != name {
continue
}
attrs := spanAttributes(e.Attributes)
if attrs[key] == value {
return true
}
}
return false
}
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
@@ -720,10 +663,7 @@ func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
{name: "auth", kind: ai.ErrorKindAuth, want: "auth"},
{name: "configuration", kind: ai.ErrorKindConfiguration, want: "configuration"},
{name: "unavailable", kind: ai.ErrorKindUnavailable, want: "unavailable"},
{name: "provider", kind: ai.ErrorKindProvider, want: "provider_error"},
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
-72
View File
@@ -29,7 +29,6 @@ var _ server.Option
type AgentService interface {
Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error)
StreamChat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (Agent_StreamChatService, error)
}
type agentService struct {
@@ -54,40 +53,6 @@ func (c *agentService) Chat(ctx context.Context, in *ChatRequest, opts ...client
return out, nil
}
func (c *agentService) StreamChat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (Agent_StreamChatService, error) {
req := c.c.NewRequest(c.name, "Agent.StreamChat", in)
stream, err := c.c.Stream(ctx, req, opts...)
if err != nil {
return nil, err
}
if err := stream.Send(in); err != nil {
_ = stream.Close()
return nil, err
}
return &agentServiceStreamChat{stream}, nil
}
type Agent_StreamChatService interface {
Close() error
Recv() (*ChatResponse, error)
}
type agentServiceStreamChat struct {
stream client.Stream
}
func (x *agentServiceStreamChat) Close() error {
return x.stream.Close()
}
func (x *agentServiceStreamChat) Recv() (*ChatResponse, error) {
m := new(ChatResponse)
if err := x.stream.Recv(m); err != nil {
return nil, err
}
return m, nil
}
// Server API for Agent service
type AgentHandler interface {
@@ -97,7 +62,6 @@ type AgentHandler interface {
func RegisterAgentHandler(s server.Server, hdlr AgentHandler, opts ...server.HandlerOption) error {
type agent interface {
Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error
StreamChat(ctx context.Context, stream server.Stream) error
}
type Agent struct {
agent
@@ -113,39 +77,3 @@ type agentHandler struct {
func (h *agentHandler) Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error {
return h.AgentHandler.Chat(ctx, in, out)
}
func (h *agentHandler) StreamChat(ctx context.Context, stream server.Stream) error {
streamer, ok := h.AgentHandler.(interface {
StreamChat(context.Context, Agent_StreamChatStream) error
})
if !ok {
return fmt.Errorf("agent: StreamChat unsupported")
}
return streamer.StreamChat(ctx, &agentStreamChatStream{stream})
}
type Agent_StreamChatStream interface {
Close() error
Send(*ChatResponse) error
Recv() (*ChatRequest, error)
}
type agentStreamChatStream struct {
stream server.Stream
}
func (x *agentStreamChatStream) Close() error {
return x.stream.Close()
}
func (x *agentStreamChatStream) Send(m *ChatResponse) error {
return x.stream.Send(m)
}
func (x *agentStreamChatStream) Recv() (*ChatRequest, error) {
m := new(ChatRequest)
if err := x.stream.Recv(m); err != nil {
return nil, err
}
return m, nil
}
-1
View File
@@ -7,7 +7,6 @@ option go_package = "./proto;agent";
// Agent is the RPC interface for an AI agent.
service Agent {
rpc Chat(ChatRequest) returns (ChatResponse) {}
rpc StreamChat(ChatRequest) returns (stream ChatResponse) {}
}
message ChatRequest {
-6
View File
@@ -327,12 +327,6 @@ func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
}
if runs[0].Steps[0].Attempts != 1 {
t.Fatalf("step attempts = %d, want 1", runs[0].Steps[0].Attempts)
}
if got := runs[0].Steps[0].ErrorKind; got != string(ai.ClassifyError(tt.err)) {
t.Fatalf("step error kind = %q, want %q", got, ai.ClassifyError(tt.err))
}
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
}
-32
View File
@@ -118,14 +118,8 @@ func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T) {
var sawRequest bool
var sawRunInfo bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
sawRequest = true
info, ok := ai.RunInfoFrom(ctx)
if !ok || info.RunID == "" || info.Agent != "provider-stream" {
t.Fatalf("RunInfo = %#v, %v; want provider stream run metadata", info, ok)
}
sawRunInfo = true
if req.Prompt != "stream the answer" {
t.Fatalf("Prompt = %q, want stream the answer", req.Prompt)
}
@@ -159,9 +153,6 @@ func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T)
if !sawRequest {
t.Fatal("provider Stream was not called")
}
if !sawRunInfo {
t.Fatal("provider Stream did not receive RunInfo")
}
if reply != "hello" {
t.Fatalf("reply = %q, want hello", reply)
}
@@ -171,29 +162,6 @@ func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T)
}
}
func TestAgentStreamCanceledContextSkipsProviderCallAndMemory(t *testing.T) {
calls := 0
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
calls++
return &sliceStream{chunks: []string{"late"}}, nil
}
defer func() { fakeStream = nil }()
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := newTestAgent(Name("provider-stream-cancel"))
_, err := a.Stream(ctx, "do not start")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Stream error = %v, want context canceled", err)
}
if calls != 0 {
t.Fatalf("provider Stream calls = %d, want 0 after caller cancellation", calls)
}
if got := a.mem.Messages(); len(got) != 0 {
t.Fatalf("memory = %#v, want no recorded canceled stream turn", got)
}
}
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
-21
View File
@@ -263,27 +263,6 @@ func textToolArguments(raw any) map[string]any {
return nil
}
func containsNestedTextToolCall(v any) bool {
switch x := v.(type) {
case string:
text := html.UnescapeString(x)
return openingTaggedToolCall.MatchString(text) || singleTaggedToolCall.MatchString(text)
case map[string]any:
for _, item := range x {
if containsNestedTextToolCall(item) {
return true
}
}
case []any:
for _, item := range x {
if containsNestedTextToolCall(item) {
return true
}
}
}
return false
}
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
-1
View File
@@ -19,7 +19,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("anthropic")
ai.RegisterToolStream("anthropic")
}
// Provider implements the ai.Model interface for Anthropic Claude
+1 -236
View File
@@ -139,33 +139,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
}
if toolName := atlascloudPartialTextToolCallName(resp.Reply, req.Tools); toolName != "" {
repairReq := map[string]any{
"model": p.opts.Model,
"messages": append(append([]map[string]any(nil), messages...),
map[string]any{"role": "assistant", "content": resp.Reply},
map[string]any{"role": "user", "content": fmt.Sprintf("Your previous response started a %q tool call but did not finish valid tool-call markup or JSON arguments, so no tool was executed. Retry the same step now by emitting one complete valid tool call for %q. Do not describe the action in prose, and do not claim completion until the tool call succeeds.", toolName, toolName)},
),
}
if p.opts.MaxTokens > 0 {
repairReq["max_tokens"] = p.opts.MaxTokens
}
if len(tools) > 0 {
repairReq["tools"] = tools
}
resp, rawMessage, err = p.callAPI(ctx, "chat-partial-tool-repair", repairReq)
if err != nil {
return nil, fmt.Errorf("atlascloud partial text tool-call repair failed for %q: %w", toolName, err)
}
if atlascloudPartialTextToolCallName(resp.Reply, req.Tools) != "" && len(resp.ToolCalls) == 0 {
fallback := atlascloudFallbackTextToolCall(toolName, req)
if fallback == "" {
return nil, fmt.Errorf("atlascloud returned incomplete text tool call for %q after repair", toolName)
}
resp.Reply = fallback
}
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
@@ -229,7 +202,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// inspects Reply for text fallback calls after Generate returns.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = atlascloudAnswerWithRequiredToolMarkers(followUpResp.Reply, toolResults, allToolCalls)
resp.Answer = followUpResp.Reply
}
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
@@ -249,27 +222,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return resp, nil
}
func atlascloudAnswerWithRequiredToolMarkers(answer string, toolResults []string, toolCalls []ai.ToolCall) string {
if strings.Contains(answer, "agent-conformance") || !atlascloudSawRefusedDelegate(toolCalls) {
return answer
}
for _, result := range toolResults {
if strings.Contains(result, "agent-conformance") {
return strings.TrimSpace(answer + "\n" + result)
}
}
return answer
}
func atlascloudSawRefusedDelegate(toolCalls []ai.ToolCall) bool {
for _, call := range toolCalls {
if call.Name == "delegate" && call.Error != "" {
return true
}
}
return false
}
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
// chat completions endpoint, emitting content deltas as they arrive.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
@@ -527,193 +479,6 @@ func atlascloudToolCallsText(calls any) string {
return string(b)
}
func atlascloudPartialTextToolCallName(text string, tools []ai.Tool) string {
if !strings.Contains(text, "<tool_call") {
return ""
}
if strings.Contains(text, "</tool_call>") {
return ""
}
for _, tool := range tools {
for _, name := range []string{tool.Name, tool.OriginalName} {
if name == "" {
continue
}
if strings.Contains(text, `name="`+name+`"`) || strings.Contains(text, `name='`+name+`'`) {
return tool.Name
}
}
}
return ""
}
func atlascloudFallbackTextToolCall(toolName string, req *ai.Request) string {
switch toolName {
case "plan":
return atlascloudPlanFallbackTextToolCall(req.Prompt)
case "delegate":
return atlascloudDelegateFallbackTextToolCall(req)
default:
if atlascloudToolTakesNoArguments(toolName, req.Tools) {
return atlascloudEmptyArgumentFallbackTextToolCall(toolName)
}
return atlascloudServiceFallbackTextToolCall(toolName, req)
}
}
func atlascloudServiceFallbackTextToolCall(toolName string, req *ai.Request) string {
if req == nil {
return ""
}
for _, tool := range req.Tools {
if tool.Name != toolName {
continue
}
args := atlascloudFallbackArgsForProperties(tool.Properties, atlascloudRequestText(req))
if args == nil {
return ""
}
b, err := json.Marshal(args)
if err != nil {
return ""
}
return `<tool_call name="` + toolName + `">` + string(b) + `</tool_call>`
}
return ""
}
func atlascloudFallbackArgsForProperties(properties map[string]any, ctxText string) map[string]any {
if len(properties) == 0 {
return map[string]any{}
}
args := make(map[string]any, len(properties))
for name, schema := range properties {
value, ok := atlascloudFallbackArgValue(name, schema, ctxText)
if !ok {
return nil
}
args[name] = value
}
return args
}
func atlascloudFallbackArgValue(name string, schema any, ctxText string) (any, bool) {
typeName := "string"
if m, ok := schema.(map[string]any); ok {
if t, _ := m["type"].(string); t != "" {
typeName = t
}
}
switch typeName {
case "string":
return atlascloudFallbackStringArg(name, ctxText)
default:
return nil, false
}
}
func atlascloudFallbackStringArg(name, ctxText string) (string, bool) {
ctxText = strings.TrimSpace(ctxText)
if ctxText == "" {
return "", false
}
if strings.Contains(strings.ToLower(name), "email") || strings.Contains(strings.ToLower(name), "owner") {
if email := atlascloudFirstEmail(ctxText); email != "" {
return email, true
}
}
return ctxText, true
}
func atlascloudFirstEmail(text string) string {
for _, field := range strings.FieldsFunc(text, func(r rune) bool {
return strings.ContainsRune(" \t\n\r<>\"'(),;", r)
}) {
field = strings.Trim(field, ".:")
if strings.Contains(field, "@") && strings.Contains(field, ".") {
return field
}
}
return ""
}
func atlascloudToolTakesNoArguments(toolName string, tools []ai.Tool) bool {
for _, tool := range tools {
if tool.Name != toolName {
continue
}
return len(tool.Properties) == 0
}
return false
}
func atlascloudEmptyArgumentFallbackTextToolCall(toolName string) string {
if toolName == "" {
return ""
}
return `<tool_call name="` + toolName + `">{}</tool_call>`
}
func atlascloudPlanFallbackTextToolCall(prompt string) string {
task := strings.TrimSpace(prompt)
if task == "" {
task = "continue the requested work"
}
args, err := json.Marshal(map[string]any{
"steps": []map[string]string{{
"task": task,
"status": "pending",
}},
})
if err != nil {
return `<tool_call name="plan">{"steps":[{"task":"continue the requested work","status":"pending"}]}</tool_call>`
}
return `<tool_call name="plan">` + string(args) + `</tool_call>`
}
func atlascloudDelegateFallbackTextToolCall(req *ai.Request) string {
ctxText := atlascloudRequestText(req)
task := strings.TrimSpace(req.Prompt)
if task == "" {
task = strings.TrimSpace(ctxText)
}
if task == "" {
task = "continue the requested delegated work"
}
args := map[string]any{"task": task}
if strings.Contains(strings.ToLower(ctxText), "comms") {
args["to"] = "comms"
}
b, err := json.Marshal(args)
if err != nil {
return `<tool_call name="delegate">{"task":"continue the requested delegated work"}</tool_call>`
}
return `<tool_call name="delegate">` + string(b) + `</tool_call>`
}
func atlascloudRequestText(req *ai.Request) string {
if req == nil {
return ""
}
var parts []string
if req.SystemPrompt != "" {
parts = append(parts, req.SystemPrompt)
}
for _, msg := range req.Messages {
switch c := msg.Content.(type) {
case string:
parts = append(parts, c)
default:
parts = append(parts, fmt.Sprint(c))
}
}
if req.Prompt != "" {
parts = append(parts, req.Prompt)
}
return strings.Join(parts, "\n")
}
func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
return nil, ""
-178
View File
@@ -403,9 +403,6 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
if !strings.Contains(resp.Answer, "blocked by policy") {
t.Fatalf("Answer = %q, want follow-up tool result", resp.Answer)
}
if !strings.Contains(resp.Answer, "agent-conformance-ok") {
t.Fatalf("Answer = %q, want conformance marker preserved from tool result", resp.Answer)
}
if _, ok := bodies[1]["tools"].([]any); !ok {
t.Fatalf("follow-up request did not include tools: %#v", bodies[1])
}
@@ -523,181 +520,6 @@ func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
}
}
func TestProvider_GenerateRepairsInitialPartialTextToolCall(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_call name=\"plan\">"}}]}`))
case 2:
messages := body["messages"].([]any)
last := messages[len(messages)-1].(map[string]any)
if last["role"] != "user" || !strings.Contains(last["content"].(string), "did not finish valid tool-call markup") {
t.Fatalf("repair prompt = %#v, want partial tool-call guidance", last)
}
if _, ok := body["tools"]; !ok {
t.Fatalf("repair request did not keep tools available: %#v", body)
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"plan\">{\"steps\":[{\"task\":\"create tasks\"}]}</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: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="plan">`) || !strings.Contains(resp.Reply, `</tool_call>`) {
t.Fatalf("Reply = %q, want completed text tool call", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus repair", len(bodies))
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialPlanTextToolCall(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"plan\">"}}]}`))
}))
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: "plan", Description: "record a plan"}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="plan">`) || !strings.Contains(resp.Reply, `</tool_call>`) {
t.Fatalf("Reply = %q, want completed fallback plan text tool call", resp.Reply)
}
if !strings.Contains(resp.Reply, "plan and delegate") {
t.Fatalf("Reply = %q, want fallback plan seeded from prompt", resp.Reply)
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialDelegateTextToolCall(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">"}}]}`))
}))
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{
SystemPrompt: "You coordinate launch work and delegate readiness notifications to the comms agent.",
Prompt: "delegate the owner readiness notification to comms",
Tools: []ai.Tool{{Name: "delegate", Description: "delegate work"}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
for _, want := range []string{`<tool_call name="delegate">`, `"task":"delegate the owner readiness notification to comms"`, `"to":"comms"`, `</tool_call>`} {
if !strings.Contains(resp.Reply, want) {
t.Fatalf("Reply = %q, want delegate fallback containing %q", resp.Reply, want)
}
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialNoArgumentServiceTextToolCall(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"task_TaskService_List\">"}}]}`))
}))
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: "list the current launch-readiness tasks",
Tools: []ai.Tool{{
Name: "task_TaskService_List",
OriginalName: "task.TaskService.List",
Description: "List persisted launch-readiness tasks",
Properties: map[string]any{},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
want := `<tool_call name="task_TaskService_List">{}</tool_call>`
if resp.Reply != want {
t.Fatalf("Reply = %q, want %q", resp.Reply, want)
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialWorkspaceServiceTextToolCall(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")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"workspace_WorkspaceService_Create\">"}}]}`))
}))
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{
SystemPrompt: "Create an onboarding workspace only if it is still needed.",
Prompt: "Onboard alice@acme.com. The workspace create side effect may already be complete; avoid failing the flow on a duplicate repaired call.",
Tools: []ai.Tool{{
Name: "workspace_WorkspaceService_Create",
OriginalName: "workspace.WorkspaceService.Create",
Description: "Create an onboarding workspace",
Properties: map[string]any{"owner": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
want := `<tool_call name="workspace_WorkspaceService_Create">{"owner":"alice@acme.com"}</tool_call>`
if resp.Reply != want {
t.Fatalf("Reply = %q, want %q", resp.Reply, want)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus repair", len(bodies))
}
}
func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+5 -24
View File
@@ -23,10 +23,6 @@ type Capabilities struct {
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
// leave this false until their Stream implementation is usable.
Stream bool `json:"stream"`
// ToolStream reports whether the provider supports agent Stream requests that
// include tool schemas. Providers may support plain token streaming while
// leaving this false when their streaming API cannot accept tools.
ToolStream bool `json:"tool_stream"`
}
// ProviderCapabilities reports the capabilities registered for provider.
@@ -35,14 +31,12 @@ func ProviderCapabilities(provider string) Capabilities {
_, hasImage := imageProviders[provider]
_, hasVideo := videoProviders[provider]
_, hasStream := streamProviders[provider]
_, hasToolStream := toolStreamProviders[provider]
return Capabilities{
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
ToolStream: hasToolStream,
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
}
}
@@ -64,9 +58,6 @@ func CapabilityMatrix() map[string]Capabilities {
for name := range streamProviders {
names[name] = struct{}{}
}
for name := range toolStreamProviders {
names[name] = struct{}{}
}
matrix := make(map[string]Capabilities, len(names))
for name := range names {
@@ -97,18 +88,10 @@ func RegisterStream(provider string) {
streamProviders[provider] = struct{}{}
}
// RegisterToolStream records that provider can accept tool schemas in Stream
// requests. This is intentionally separate from RegisterStream because some
// providers can stream tokens but cannot expose tools while streaming.
func RegisterToolStream(provider string) {
toolStreamProviders[provider] = struct{}{}
}
var streamProviders = make(map[string]struct{})
var toolStreamProviders = make(map[string]struct{})
// RegisteredProviders returns the registered provider names in sorted order.
// kind may be "model", "image", "video", "stream", "tool_stream", or empty for the union of all
// kind may be "model", "image", "video", "stream", or empty for the union of all
// provider registries.
func RegisteredProviders(kind string) []string {
names := map[string]struct{}{}
@@ -138,8 +121,6 @@ func RegisteredProviders(kind string) []string {
add(providers)
case "stream":
add(streamProviders)
case "tool_stream":
add(toolStreamProviders)
case "image":
add(imageProviders)
case "video":
+10 -24
View File
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
}
got = ai.RegisteredProviders("stream")
want = []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
want = []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
@@ -44,14 +44,14 @@ func TestRegisteredProviders(t *testing.T) {
func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true, ToolStream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
@@ -71,7 +71,7 @@ func TestCapabilityMatrix(t *testing.T) {
}
}
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true, ToolStream: true}) {
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
@@ -90,22 +90,8 @@ func TestRegisterStream(t *testing.T) {
}
got := ai.RegisteredProviders("stream")
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
func TestRegisterToolStream(t *testing.T) {
ai.RegisterToolStream("test-tool-stream")
if caps := ai.ProviderCapabilities("test-tool-stream"); caps != (ai.Capabilities{ToolStream: true}) {
t.Fatalf("ProviderCapabilities(test-tool-stream) = %#v", caps)
}
got := ai.RegisteredProviders("tool_stream")
want := []string{"anthropic", "groq", "minimax", "mistral", "openai", "test-tool-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(tool_stream) = %#v, want %#v", got, want)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Package flow is maintained for backward compatibility.
// The canonical import is go-micro.dev/v6/flow.
package flow
import "go-micro.dev/v6/flow"
// Re-export types for backward compatibility.
type Flow = flow.Flow
type Options = flow.Options
type Option = flow.Option
type Result = flow.Result
var New = flow.New
var Trigger = flow.Trigger
var Prompt = flow.Prompt
var SystemPrompt = flow.SystemPrompt
var Provider = flow.Provider
var APIKey = flow.APIKey
var Model = flow.Model
var BaseURL = flow.BaseURL
var HistoryLimit = flow.HistoryLimit
var OnResult = flow.OnResult
+4 -136
View File
@@ -10,7 +10,6 @@
package gemini
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -26,7 +25,6 @@ func init() {
ai.Register("gemini", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("gemini")
}
// Provider implements the ai.Model interface for Google Gemini.
@@ -71,7 +69,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
})
}
contents := geminiContents(req)
contents := []map[string]any{
{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}},
}
apiReq := map[string]any{
"contents": contents,
@@ -135,121 +135,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
apiReq := map[string]any{
"contents": geminiContents(req),
}
if req.SystemPrompt != "" {
apiReq["system_instruction"] = map[string]any{
"parts": []map[string]any{{"text": req.SystemPrompt}},
}
}
if p.opts.MaxTokens > 0 {
apiReq["generationConfig"] = map[string]any{"maxOutputTokens": p.opts.MaxTokens}
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
"/v1beta/models/" + p.opts.Model + ":streamGenerateContent?alt=sse"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("x-goog-api-key", p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, ai.NewHTTPError(httpResp, respBody)
}
return &streamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type streamReader struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *streamReader) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
UsageMetadata *struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Error != nil {
return nil, fmt.Errorf("gemini stream error (%s): %s", chunk.Error.Status, chunk.Error.Message)
}
for _, candidate := range chunk.Candidates {
var parts []string
for _, part := range candidate.Content.Parts {
if part.Text != "" {
parts = append(parts, part.Text)
}
}
if len(parts) > 0 {
return &ai.Response{Reply: strings.Join(parts, "")}, nil
}
}
if chunk.UsageMetadata != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.UsageMetadata.PromptTokenCount,
OutputTokens: chunk.UsageMetadata.CandidatesTokenCount,
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *streamReader) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
@@ -338,21 +224,3 @@ type functionCallPB struct {
Name string `json:"name"`
Args map[string]any `json:"args"`
}
func geminiContents(req *ai.Request) []map[string]any {
contents := make([]map[string]any, 0, len(req.Messages)+1)
for _, m := range req.Messages {
role := m.Role
if role == "assistant" {
role = "model"
}
if role == "system" || role == "" {
continue
}
contents = append(contents, map[string]any{"role": role, "parts": []map[string]any{{"text": fmt.Sprint(m.Content)}}})
}
if req.Prompt != "" {
contents = append(contents, map[string]any{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}})
}
return contents
}
+6 -103
View File
@@ -2,12 +2,7 @@ package gemini
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
@@ -86,108 +81,16 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
if r.URL.Path != "/v1beta/models/gemini-2.5-flash:streamGenerateContent" {
t.Fatalf("path = %s, want streamGenerateContent", r.URL.Path)
}
if r.URL.Query().Get("alt") != "sse" {
t.Fatalf("alt = %q, want sse", r.URL.Query().Get("alt"))
}
if got := r.Header.Get("Accept"); got != "text/event-stream" {
t.Fatalf("Accept = %q, want text/event-stream", got)
}
if got := r.Header.Get("x-goog-api-key"); got != "test-key" {
t.Fatalf("x-goog-api-key = %q, want test-key", got)
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
contents, ok := body["contents"].([]any)
if !ok || len(contents) != 3 {
t.Fatalf("contents = %#v, want history + prompt", body["contents"])
}
second := contents[1].(map[string]any)
if second["role"] != "model" {
t.Fatalf("assistant history role = %#v, want model", second["role"])
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hel\"}]}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"lo\"}]}}],\"usageMetadata\":{\"promptTokenCount\":3,\"candidatesTokenCount\":2,\"totalTokenCount\":5}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{
Messages: []ai.Message{
{Role: "user", Content: "previous question"},
{Role: "assistant", Content: "previous answer"},
},
req := &ai.Request{
Prompt: "Hello",
})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawRequest {
t.Fatal("server did not receive stream request")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {bad json}\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if _, err := stream.Recv(); err == nil {
t.Fatal("Recv returned nil error for malformed chunk")
}
}
func TestProvider_StreamPropagatesProviderError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "quota exhausted", http.StatusTooManyRequests)
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for provider failure")
}
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "quota exhausted") {
t.Fatalf("Stream error = %v, want provider status and body", err)
}
if strings.Contains(err.Error(), "test-key") {
t.Fatal("stream error leaked API key")
_, err := p.Stream(context.Background(), req)
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
-1
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
ai.RegisterToolStream("groq")
}
type Provider struct {
-1
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
ai.RegisterToolStream("minimax")
}
type Provider struct {
-1
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("mistral")
ai.RegisterToolStream("mistral")
}
type Provider struct {
-5
View File
@@ -109,9 +109,6 @@ const (
RefusedMaxSteps = "max_steps"
RefusedLoop = "loop"
RefusedApproval = "approval"
// RefusedSpendBudget means an agent refused a paid tool before execution
// because the configured per-run x402 spend budget would be exceeded.
RefusedSpendBudget = "spend_budget"
)
// RunInfo describes the agent run a tool call belongs to. The agent
@@ -135,8 +132,6 @@ type RunInfo struct {
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
Trigger string // external trigger or schedule label that started the run, when known
Spent int64 // cumulative paid x402 spend in this run, in the asset's smallest unit
ToolSpend int64 // paid x402 spend attributed to the current tool call, in the asset's smallest unit
}
type runInfoKey struct{}
-1
View File
@@ -45,7 +45,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
ai.RegisterToolStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
-1
View File
@@ -22,7 +22,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("openai")
ai.RegisterToolStream("openai")
}
// Provider implements the ai.Model interface for OpenAI
+7 -28
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"strconv"
"strings"
@@ -85,14 +84,12 @@ func parseRetryAfter(value string, now time.Time) time.Duration {
type ErrorKind string
const (
ErrorKindUnknown ErrorKind = "unknown"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindRateLimited ErrorKind = "rate_limited"
ErrorKindUnavailable ErrorKind = "unavailable"
ErrorKindAuth ErrorKind = "auth"
ErrorKindConfiguration ErrorKind = "configuration"
ErrorKindProvider ErrorKind = "provider"
ErrorKindUnknown ErrorKind = "unknown"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindRateLimited ErrorKind = "rate_limited"
ErrorKindUnavailable ErrorKind = "unavailable"
ErrorKindProvider ErrorKind = "provider"
)
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
@@ -133,9 +130,6 @@ type GeneratePolicy struct {
Timeout time.Duration
MaxAttempts int
Backoff time.Duration
// Jitter adds up to this duration of random delay to retry backoff.
// It is opt-in so existing retry timing remains deterministic by default.
Jitter time.Duration
}
// GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry.
@@ -189,7 +183,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
// Always back off between retries — exponential and capped — so an
// opt-in retry can never become a tight loop hammering the provider,
// even if Backoff was left at zero.
backoff := retryBackoffWithJitter(err, attempt, policy.Backoff, policy.Jitter)
backoff := retryBackoff(err, attempt, policy.Backoff)
t := time.NewTimer(backoff)
select {
case <-ctx.Done():
@@ -225,10 +219,6 @@ func generateAttempt(ctx context.Context, m Model, req *Request, opts ...Generat
}
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
return retryBackoffWithJitter(err, attempt, base, 0)
}
func retryBackoffWithJitter(err error, attempt int, base, jitter time.Duration) time.Duration {
backoff := base
if backoff <= 0 {
backoff = 200 * time.Millisecond
@@ -246,9 +236,6 @@ func retryBackoffWithJitter(err error, attempt int, base, jitter time.Duration)
backoff = delay
}
}
if jitter > 0 {
backoff += time.Duration(rand.Int64N(int64(jitter) + 1))
}
if backoff > 30*time.Second {
return 30 * time.Second
}
@@ -278,10 +265,6 @@ func ClassifyError(err error) ErrorKind {
switch {
case code == 429:
return ErrorKindRateLimited
case code == 401 || code == 403:
return ErrorKindAuth
case code == 400 || code == 404:
return ErrorKindConfiguration
case code >= 500:
return ErrorKindUnavailable
case code > 0:
@@ -294,10 +277,6 @@ func ClassifyError(err error) ErrorKind {
return ErrorKindRateLimited
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
return ErrorKindTimeout
case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "invalid api key") || strings.Contains(msg, "api key") || strings.Contains(msg, "credential"):
return ErrorKindAuth
case strings.Contains(msg, "missing") || strings.Contains(msg, "not configured") || strings.Contains(msg, "configuration") || strings.Contains(msg, "unsupported model") || strings.Contains(msg, "model not found"):
return ErrorKindConfiguration
case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"):
return ErrorKindUnavailable
default:
+1 -169
View File
@@ -69,50 +69,6 @@ func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
}
}
func TestGenerateWithRetryCancellationDuringBackoffStopsRetry(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
attempts := 0
firstAttemptDone := make(chan struct{})
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
if attempts == 1 {
close(firstAttemptDone)
return nil, retryAfterErr{delay: time.Hour}
}
return &Response{Reply: "unexpected retry"}, nil
}}
errc := make(chan error, 1)
go func() {
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Hour,
})
errc <- err
}()
select {
case <-firstAttemptDone:
case <-time.After(time.Second):
t.Fatal("first provider attempt did not run")
}
cancel()
select {
case err := <-errc:
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
case <-time.After(time.Second):
t.Fatal("GenerateWithRetry did not stop after cancellation during backoff")
}
if attempts != 1 {
t.Fatalf("attempts = %d, want cancellation to prevent retry", attempts)
}
}
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
var attempts atomic.Int32
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
@@ -230,13 +186,9 @@ func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
{name: "canceled", err: context.Canceled, want: ErrorKindCanceled},
{name: "timeout", err: context.DeadlineExceeded, want: ErrorKindTimeout},
{name: "rate limit status", err: statusErr(429), want: ErrorKindRateLimited},
{name: "auth status", err: statusErr(401), want: ErrorKindAuth},
{name: "configuration status", err: statusErr(400), want: ErrorKindConfiguration},
{name: "unavailable status", err: statusErr(503), want: ErrorKindUnavailable},
{name: "provider status", err: statusErr(409), want: ErrorKindProvider},
{name: "provider status", err: statusErr(400), want: ErrorKindProvider},
{name: "rate limit text", err: errors.New("rate limit exceeded"), want: ErrorKindRateLimited},
{name: "auth text", err: errors.New("invalid API key"), want: ErrorKindAuth},
{name: "configuration text", err: errors.New("model not found"), want: ErrorKindConfiguration},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -300,114 +252,6 @@ func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
}
}
func TestGenerateWithRetryDoesNotRetryPermanentProviderErrors(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
return nil, statusErr(400)
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Millisecond,
})
if !errors.Is(err, statusErr(400)) {
t.Fatalf("error = %v, want original provider status", err)
}
var retryErr *RetryError
if errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want permanent provider error without retry wrapper", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want no retry for permanent provider errors", attempts)
}
}
func TestGenerateWithRetryDefaultsToSingleAttempt(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
return nil, errors.New("temporary provider outage")
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want retry error for exhausted transient attempt", err)
}
if retryErr.Attempts != 1 {
t.Fatalf("retry attempts = %d, want default single attempt", retryErr.Attempts)
}
if attempts != 1 {
t.Fatalf("model attempts = %d, want default single attempt", attempts)
}
}
func TestGenerateWithRetryStopsDuringBackoffWhenCallerCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var attempts atomic.Int32
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts.Add(1)
return nil, statusErr(503)
}}
errc := make(chan error, 1)
go func() {
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Hour,
})
errc <- err
}()
deadline := time.After(time.Second)
for attempts.Load() == 0 {
select {
case err := <-errc:
t.Fatalf("GenerateWithRetry returned before first attempt cancellation: %v", err)
case <-deadline:
t.Fatal("provider was not called")
default:
time.Sleep(time.Millisecond)
}
}
start := time.Now()
cancel()
select {
case err := <-errc:
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("GenerateWithRetry did not stop promptly during backoff cancellation")
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("backoff cancellation took %s, want prompt return", elapsed)
}
if got := attempts.Load(); got != 1 {
t.Fatalf("attempts = %d, want cancellation before retry", got)
}
}
func TestRetryBackoffUsesExponentialBaseAndCap(t *testing.T) {
if got := retryBackoff(statusErr(503), 1, 10*time.Millisecond); got != 10*time.Millisecond {
t.Fatalf("attempt 1 backoff = %s, want 10ms", got)
}
if got := retryBackoff(statusErr(503), 2, 10*time.Millisecond); got != 20*time.Millisecond {
t.Fatalf("attempt 2 backoff = %s, want 20ms", got)
}
if got := retryBackoff(statusErr(503), 3, 10*time.Millisecond); got != 40*time.Millisecond {
t.Fatalf("attempt 3 backoff = %s, want 40ms", got)
}
if got := retryBackoff(statusErr(503), 20, time.Second); got != 30*time.Second {
t.Fatalf("large backoff = %s, want 30s cap", got)
}
}
func TestHTTPErrorExposesStatusAndRetryAfter(t *testing.T) {
resp := &http.Response{
Status: "429 Too Many Requests",
@@ -427,15 +271,3 @@ func TestHTTPErrorExposesStatusAndRetryAfter(t *testing.T) {
t.Fatalf("RetryAfter() = %s, want 2s", got)
}
}
func TestRetryBackoffAddsBoundedJitter(t *testing.T) {
const base = 10 * time.Millisecond
const jitter = 5 * time.Millisecond
for range 100 {
got := retryBackoffWithJitter(errors.New("temporary"), 1, base, jitter)
if got < base || got > base+jitter {
t.Fatalf("retryBackoffWithJitter() = %s, want in [%s, %s]", got, base, base+jitter)
}
}
}
+18 -1
View File
@@ -216,7 +216,6 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
{provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY", modelEnv: "ANTHROPIC_MODEL"},
{provider: "gemini", keyEnv: "GEMINI_API_KEY", modelEnv: "GEMINI_MODEL"},
} {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
@@ -257,6 +256,24 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
}
}
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
for _, provider := range []string{"gemini"} {
provider := provider
t.Run(provider, func(t *testing.T) {
if caps := ai.ProviderCapabilities(provider); caps.Stream {
t.Fatalf("ProviderCapabilities(%q).Stream = true, want false", provider)
}
_, err := ai.New(provider, ai.WithAPIKey("test-key")).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if err != nil && strings.Contains(err.Error(), "test-key") {
t.Fatal("streaming unsupported error leaked API key")
}
})
}
}
func conformingStreamProviders(t *testing.T) []string {
t.Helper()
providers := ai.RegisteredProviders("stream")
-1
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("together")
ai.RegisterToolStream("together")
}
type Provider struct {
+4 -6
View File
@@ -62,17 +62,15 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
## First agent on-ramp
Once the scaffold → run → call path works, ask the installed CLI for the
provider-free agent path. The focused no-secret docs/CLI contract is
`make docs-wayfinding`:
provider-free agent path:
```
micro agent demo
micro agent quickcheck
micro examples
micro zero-to-hero
```
`micro agent quickcheck` (alias: `micro agent debug`) prints the short recovery map when scaffold → run → chat → inspect stalls. Those commands point at the smallest mock-model first-agent example, the no-secret transcript, and the 0→hero support app before you add provider-backed chat.
Those commands point at the smallest mock-model first-agent example, the no-secret
transcript, and the support app before you add provider-backed chat.
### Output
@@ -685,7 +683,7 @@ micro loop init \
```
- `--roles`: which roles to scaffold (`planner,builder,triage`, or `all`)
- `--agent`: how the workflows summon the agent any `@mention`-driven coding agent (e.g. `@codex`, `@claude`)
- `--agent`: how the workflows summon the agent (an `@mention`)
- `--token-secret`: repo secret holding the driving user PAT
- `--branch`: base branch for the loop's PRs
- `--ci-workflow`: `name:` of the CI workflow triage watches
-41
View File
@@ -22,7 +22,6 @@ import (
"github.com/urfave/cli/v2"
"go-micro.dev/v6/agent"
agentpb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
clt "go-micro.dev/v6/client"
"go-micro.dev/v6/cmd"
@@ -190,36 +189,6 @@ func (s *session) callAgent(ctx context.Context, name, message string) (*agent.R
return r, nil
}
// streamAgent calls an agent's StreamChat endpoint and prints chunks as they
// arrive. Agents that do not expose StreamChat return an error; callers use that
// signal to fall back to Agent.Chat.
func (s *session) streamAgent(ctx context.Context, name, message string) error {
stream, err := agentpb.NewAgentService(name, s.cl).StreamChat(ctx, &agentpb.ChatRequest{Message: message})
if err != nil {
return err
}
defer stream.Close()
var reply strings.Builder
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if chunk == nil || chunk.Reply == "" {
continue
}
fmt.Print(chunk.Reply)
reply.WriteString(chunk.Reply)
}
if reply.Len() > 0 {
fmt.Println()
}
return nil
}
// buildRouterPrompt creates a system prompt for the router that
// knows about all available agents and can dispatch to them.
func (s *session) buildRouterPrompt() string {
@@ -583,11 +552,6 @@ func (s *session) routeToAgent(ctx context.Context, prompt string) error {
if len(s.agents) == 1 {
for name := range s.agents {
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", name)
if s.stream {
if err := s.streamAgent(ctx, name, prompt); err == nil {
return nil
}
}
resp, err := s.callAgent(ctx, name, prompt)
if err != nil {
return err
@@ -626,11 +590,6 @@ func (s *session) routeToAgent(ctx context.Context, prompt string) error {
}
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", agentName)
if s.stream {
if err := s.streamAgent(ctx, agentName, message); err == nil {
return ai.ToolResult{ID: call.ID, Value: map[string]string{"agent": agentName, "streamed": "true"}, Content: `{"streamed":true}`}
}
}
resp, err := s.callAgent(ctx, agentName, message)
if err != nil {
return ai.ToolResult{ID: call.ID, Value: map[string]string{"error": err.Error()}, Content: `{"error":"` + err.Error() + `"}`}
+1 -169
View File
@@ -2,48 +2,18 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/urfave/cli/v2"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/cmd"
aiflow "go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
const firstAgentQuickChecksHelp = `First-agent failure-mode quick checks
Use this when scaffold -> run -> chat -> inspect stalls and you want the
smallest provider-free recovery loop before reading the full docs.
1. Confirm prerequisites before starting the gateway:
micro agent preflight
2. Start the project and keep it running in a separate terminal:
micro run
3. Check the agent is registered and the chat gateway is reachable:
micro agent doctor
4. If chat returns an answer or an error, inspect the latest run state:
micro inspect agent <name>
micro runs <name>
5. If provider chat is not configured yet, prove the no-secret path still works:
micro agent demo
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
Recovery docs:
https://go-micro.dev/docs/guides/debugging-agents.html
https://go-micro.dev/docs/guides/no-secret-first-agent.html`
const noSecretDemoHelp = `No-secret first-agent demo
Use this when you want the fastest provider-free agent success path before
@@ -56,7 +26,6 @@ What this proves:
- service tools can be called by an agent
- chat behavior is exercised without contacting a live provider
- run history can be inspected after the prompt
- the debug smoke seeds a stalled-first-agent recovery transcript
After it passes:
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
@@ -68,10 +37,7 @@ Use live-provider chat when you are ready for real model behavior:
micro run
micro chat
micro agent doctor # after micro run: chat/gateway/inspect recovery
micro inspect agent <name>
Debug transcript smoke:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`
micro inspect agent <name>`
func init() {
cmd.Register(&cli.Command{
@@ -106,18 +72,6 @@ for live-provider chat and inspect/debugging.`,
return nil
},
},
{
Name: "quickcheck",
Aliases: []string{"debug"},
Usage: "Print first-agent failure-mode quick checks",
Description: `Print provider-free recovery breadcrumbs for the scaffold -> run ->
chat -> inspect loop, including exact commands for registration, gateway, run
history, and no-secret fallback checks.`,
Action: func(c *cli.Context) error {
fmt.Fprintln(c.App.Writer, firstAgentQuickChecksHelp)
return nil
},
},
{
Name: "preflight",
Usage: "Check local prerequisites before the first provider-backed agent",
@@ -195,23 +149,6 @@ history, and no-secret fallback checks.`,
return nil
},
},
{
Name: "resume-input",
Usage: "Continue an input-required agent run with human input",
ArgsUsage: "[name] [run-id]",
Flags: []cli.Flag{
&cli.StringFlag{Name: "input", Usage: "Human input to provide to the paused run", Required: true},
},
Action: func(c *cli.Context) error {
name := c.Args().First()
runID := c.Args().Get(1)
if name == "" || runID == "" {
return fmt.Errorf("usage: micro agent resume-input [name] [run-id] --input <text>")
}
return resumeInputRun(context.Background(), c.App.Writer, name, runID, c.String("input"))
},
},
{
Name: "history",
Usage: "Show an agent's stored conversation and run history",
@@ -287,44 +224,14 @@ func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON b
if run.TraceID != "" {
line += " trace=" + shortTraceID(run.TraceID)
}
if run.Checkpoint != "" {
line += " checkpoint=" + run.Checkpoint
}
if run.Stage != "" {
line += " stage=" + run.Stage
}
if run.LastError != "" {
line += " error=" + run.LastError
}
fmt.Fprintln(w, line)
writeRunIndexBreadcrumbs(w, name, run)
}
return nil
}
func writeRunIndexBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
if run.Stage == "input-required" {
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
return
}
if !isResumableRunSummary(run) {
return
}
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
fmt.Fprintf(w, " stream: call micro.ResumeStreamAsk(ctx, agent, %q) to resume with streaming events\n", run.RunID)
}
func isResumableRunSummary(run goagent.RunSummary) bool {
switch run.Status {
case "running", "error", "failed", "refused":
return run.Checkpoint != "done" || run.Stage != ""
default:
return false
}
}
func printRunHistory(name, runID string, asJSON bool) error {
events, err := goagent.LoadRunEvents(store.DefaultStore, name, runID)
if err != nil {
@@ -390,78 +297,3 @@ func shortTraceID(id string) string {
}
return id[:12]
}
type cliInputPause struct {
OriginalMessage string `json:"original_message"`
Prompt string `json:"prompt"`
}
func resumeInputRun(ctx context.Context, w io.Writer, name, runID, input string) error {
if input == "" {
return fmt.Errorf("input required: pass --input <text>")
}
cp := aiflow.StoreCheckpoint(store.DefaultStore, name)
run, ok, err := cp.Load(ctx, runID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("agent run %s not found for %q", runID, name)
}
if run.Status != "paused" || run.State.Stage != "input-required" {
return fmt.Errorf("agent run %s is not waiting for human input", runID)
}
var pause cliInputPause
_ = run.State.Scan(&pause)
reply := "Human input recorded; recreate the agent with the same checkpoint store and call micro.AgentResumeInput to continue model execution."
resp := goagent.Response{Reply: reply, Agent: name, RunID: runID, ParentID: run.ParentID}
data, err := json.Marshal(resp)
if err != nil {
return err
}
run.Status = "done"
run.State.Stage = "done"
run.State.Data = data
for i := range run.Steps {
if run.Steps[i].Status == "paused" || run.Steps[i].Name == "ask" {
run.Steps[i].Status = "done"
run.Steps[i].Error = ""
run.Steps[i].Result = "human input: " + input
}
}
if len(run.Steps) == 0 {
run.Steps = []aiflow.StepRecord{{Name: "ask", Status: "done", Result: "human input: " + input}}
}
if err := cp.Save(ctx, run); err != nil {
return err
}
if err := recordCLIResumeEvents(name, runID, run.ParentID); err != nil {
return err
}
if pause.Prompt != "" {
fmt.Fprintf(w, " Prompt: %s\n", pause.Prompt)
}
fmt.Fprintf(w, " Recorded input for agent %q run %s.\n", name, runID)
fmt.Fprintf(w, " Inspect: micro inspect agent %s --limit 1\n", name)
return nil
}
func recordCLIResumeEvents(name, runID, parentID string) error {
now := time.Now()
scoped := store.Scope(store.DefaultStore, "agent", name)
events := []goagent.RunEvent{
{Time: now, RunID: runID, ParentID: parentID, Agent: name, Kind: "checkpoint", Name: "done", Status: "done"},
{Time: now.Add(time.Nanosecond), RunID: runID, ParentID: parentID, Agent: name, Kind: "done"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
return err
}
key := fmt.Sprintf("runs/%s/%020d-%s", runID, e.Time.UnixNano(), e.Kind)
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
return err
}
}
return nil
}
-81
View File
@@ -2,7 +2,6 @@ package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
@@ -10,8 +9,6 @@ import (
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
aiflow "go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestWriteRunIndexJSON(t *testing.T) {
@@ -62,46 +59,6 @@ func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
}
}
func TestWriteRunIndexIncludesResumeBreadcrumbs(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-failed",
Agent: "runner",
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
Events: 3,
Status: "error",
LastKind: "tool",
Checkpoint: "failed",
Stage: "ask",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"checkpoint=failed", "stage=ask", `micro agent history runner run-failed`, `micro.AgentResume(ctx, agent, "run-failed")`, `micro.ResumeStreamAsk(ctx, agent, "run-failed")`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteRunIndexInputRequiredUsesResumeInput(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-input", Agent: "runner", Status: "running", LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{`micro agent history runner run-input`, `micro agent resume-input runner run-input --input <text>`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `micro.AgentResume(ctx, agent, "run-input")`) || strings.Contains(got, "ResumeStreamAsk") {
t.Fatalf("input-required run should point at ResumeInput only, got:\n%s", got)
}
}
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
events := []goagent.RunEvent{{
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
@@ -140,41 +97,3 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
t.Fatalf("decoded events = %#v", got)
}
}
func TestResumeInputRunCompletesCheckpointAndInspectSummary(t *testing.T) {
oldStore := store.DefaultStore
store.DefaultStore = store.NewMemoryStore()
t.Cleanup(func() { store.DefaultStore = oldStore })
ctx := context.Background()
cp := aiflow.StoreCheckpoint(store.DefaultStore, "runner")
run := aiflow.Run{ID: "run-input", Flow: "runner", Status: "paused", State: aiflow.State{Stage: "input-required"}, Steps: []aiflow.StepRecord{{Name: "ask", Status: "paused", Error: "Which region?"}}}
if err := run.State.Set(cliInputPause{OriginalMessage: "deploy", Prompt: "Which region?"}); err != nil {
t.Fatalf("set pause: %v", err)
}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("save checkpoint: %v", err)
}
var out bytes.Buffer
if err := resumeInputRun(ctx, &out, "runner", "run-input", "us-east-1"); err != nil {
t.Fatalf("resumeInputRun: %v", err)
}
if got := out.String(); !strings.Contains(got, "Recorded input") || !strings.Contains(got, "micro inspect agent runner --limit 1") {
t.Fatalf("output missing continuation hints:\n%s", got)
}
loaded, ok, err := cp.Load(ctx, "run-input")
if err != nil || !ok {
t.Fatalf("load checkpoint ok=%v err=%v", ok, err)
}
if loaded.Status != "done" || loaded.State.Stage != "done" {
t.Fatalf("loaded run status/stage = %s/%s, want done/done", loaded.Status, loaded.State.Stage)
}
summaries, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, "runner", goagent.RunListOptions{Status: "done"})
if err != nil {
t.Fatalf("summaries: %v", err)
}
if len(summaries) != 1 || summaries[0].RunID != "run-input" || summaries[0].Status != "done" {
t.Fatalf("summaries = %#v, want completed run-input", summaries)
}
}
-22
View File
@@ -73,25 +73,3 @@ func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
}
}
}
func TestAgentQuickcheckPrintsProviderFreeFailureModeBreadcrumbs(t *testing.T) {
got := firstAgentQuickChecksHelp
for _, want := range []string{
"First-agent failure-mode quick checks",
"scaffold -> run -> chat -> inspect",
"micro agent preflight",
"micro run",
"micro agent doctor",
"micro inspect agent <name>",
"micro runs <name>",
"micro agent demo",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"debugging-agents.html",
"no-secret-first-agent.html",
} {
if !strings.Contains(got, want) {
t.Fatalf("quickcheck output missing %q:\n%s", want, got)
}
}
}
+4 -4
View File
@@ -246,17 +246,17 @@ func Compose(c *cli.Context) error {
imageName = registry + "/" + imageName
}
fmt.Fprintf(&sb, " %s:\n", svc.Name)
fmt.Fprintf(&sb, " image: %s\n", imageName)
sb.WriteString(fmt.Sprintf(" %s:\n", svc.Name))
sb.WriteString(fmt.Sprintf(" image: %s\n", imageName))
if svc.Port > 0 {
fmt.Fprintf(&sb, " ports:\n - \"%d:%d\"\n", svc.Port, svc.Port)
sb.WriteString(fmt.Sprintf(" ports:\n - \"%d:%d\"\n", svc.Port, svc.Port))
}
if len(svc.Depends) > 0 {
sb.WriteString(" depends_on:\n")
for _, dep := range svc.Depends {
fmt.Fprintf(&sb, " - %s\n", dep)
sb.WriteString(fmt.Sprintf(" - %s\n", dep))
}
}
-3
View File
@@ -82,9 +82,6 @@ const docsWayfinding = `First-agent and 0→hero docs:
prove service tools, mock-model chat, and inspectable run history without
configuring a provider key.
If scaffold → run → chat → inspect stalls, print the short recovery map:
micro agent quickcheck
2. No-secret first-agent transcript
https://go-micro.dev/docs/guides/no-secret-first-agent.html
Run the maintained support agent without a provider key:
+1 -1
View File
@@ -93,7 +93,7 @@ func showDeployTargets(cfg *config.Config) error {
var sb strings.Builder
sb.WriteString("Available deploy targets:\n\n")
for name, dt := range cfg.Deploy {
fmt.Fprintf(&sb, " %s -> %s\n", name, dt.SSH)
sb.WriteString(fmt.Sprintf(" %s -> %s\n", name, dt.SSH))
}
sb.WriteString("\nDeploy with: micro deploy <target>")
return fmt.Errorf("%s", sb.String())
+14 -14
View File
@@ -502,18 +502,18 @@ func newModel(provider, apiKey, model string) ai.Model {
func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
var b strings.Builder
fmt.Fprintf(&b, "syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen)
b.WriteString(fmt.Sprintf("syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen))
fmt.Fprintf(&b, "service %s {\n", titleName)
b.WriteString(fmt.Sprintf("service %s {\n", titleName))
for _, ep := range svc.Endpoints {
fmt.Fprintf(&b, "\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name)
b.WriteString(fmt.Sprintf("\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name))
}
b.WriteString("}\n\n")
// Record message
fmt.Fprintf(&b, "message %sRecord {\n", titleName)
b.WriteString(fmt.Sprintf("message %sRecord {\n", titleName))
for i, f := range svc.Fields {
fmt.Fprintf(&b, "\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description)
b.WriteString(fmt.Sprintf("\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description))
}
b.WriteString("}\n\n")
@@ -527,12 +527,12 @@ func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
n++
}
fmt.Fprintf(&b, "}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
b.WriteString(fmt.Sprintf("}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
case "Read":
fmt.Fprintf(&b, "message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
b.WriteString(fmt.Sprintf("message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
case "Update":
b.WriteString("message UpdateRequest {\n\tstring id = 1;\n")
n := 2
@@ -540,26 +540,26 @@ func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
n++
}
fmt.Fprintf(&b, "}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
b.WriteString(fmt.Sprintf("}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
case "Delete":
b.WriteString("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")
case "List":
fmt.Fprintf(&b, "message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName)
b.WriteString(fmt.Sprintf("message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName))
default:
// Custom endpoint — use all fields as input, record as output
fmt.Fprintf(&b, "message %sRequest {\n", ep.Name)
b.WriteString(fmt.Sprintf("message %sRequest {\n", ep.Name))
n := 1
for _, f := range svc.Fields {
if f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
n++
}
fmt.Fprintf(&b, "}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName)
b.WriteString(fmt.Sprintf("}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName))
}
}
return b.String()
+7 -7
View File
@@ -31,7 +31,7 @@ func TestZeroToOneContract(t *testing.T) {
}
}
generated.assertLocalModule(t)
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Alice", "Hello Alice")
@@ -52,7 +52,7 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
}
generated.assertLocalModule(t)
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Bob", "Hello Bob")
@@ -112,7 +112,6 @@ func generateService(t *testing.T, name string, args ...string) generatedService
if err != nil {
t.Fatal(err)
}
t.Setenv("MICRO_NEW_GO_MICRO_REPLACE", repoRoot)
tmp := t.TempDir()
oldwd, err := os.Getwd()
@@ -143,7 +142,7 @@ func generateService(t *testing.T, name string, args ...string) generatedService
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
}
func (g generatedService) assertLocalModule(t *testing.T) {
func (g generatedService) replaceModule(t *testing.T) {
t.Helper()
modPath := filepath.Join(g.dir, "go.mod")
@@ -151,9 +150,10 @@ func (g generatedService) assertLocalModule(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := "replace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot)
if !strings.Contains(string(mod), want) {
t.Fatalf("generated go.mod missing local replace %q:\n%s", want, mod)
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
t.Fatal(err)
}
}
-7
View File
@@ -39,8 +39,6 @@ type config struct {
UseGoPath bool
// MicroVersion is the go-micro version to require in go.mod
MicroVersion string
// MicroReplace optionally points generated services at a local go-micro checkout.
MicroReplace string
// Files
Files []file
// Comments
@@ -71,10 +69,6 @@ func microVersion() string {
return "latest"
}
func microReplace() string {
return filepath.ToSlash(os.Getenv("MICRO_NEW_GO_MICRO_REPLACE"))
}
type file struct {
Path string
Tmpl string
@@ -221,7 +215,6 @@ func Run(ctx *cli.Context) error {
GoPath: goPath,
UseGoPath: false,
MicroVersion: microVersion(),
MicroReplace: microReplace(),
}
if useProto {
+2 -6
View File
@@ -10,9 +10,7 @@ require (
github.com/golang/protobuf latest
google.golang.org/protobuf latest
)
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
`
// ModuleNoProto is the default go.mod: no protobuf dependencies.
// MicroVersion is the version this CLI was built from (or "latest"), so a
@@ -22,7 +20,5 @@ replace go-micro.dev/v6 => {{.MicroReplace}}
go 1.23
require go-micro.dev/v6 {{.MicroVersion}}
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
`
)
-161
View File
@@ -2,8 +2,6 @@ package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -38,9 +36,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
if !subcommands["agent"]["doctor"] {
t.Fatal("first-agent walkthrough missing recovery boundary: agent doctor")
}
if !subcommands["agent"]["quickcheck"] {
t.Fatal("first-agent walkthrough missing failure-mode boundary: agent quickcheck")
}
if !subcommands["inspect"]["agent"] {
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
}
@@ -121,29 +116,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
}
}
quickcheck := subcommandByName(t, agent, "quickcheck")
out.Reset()
if err := quickcheck.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro agent quickcheck failed: %v", err)
}
for _, want := range []string{
"First-agent failure-mode quick checks",
"scaffold -> run -> chat -> inspect",
"micro agent preflight",
"micro run",
"micro agent doctor",
"micro inspect agent <name>",
"micro runs <name>",
"micro agent demo",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"debugging-agents.html",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro agent quickcheck output missing %q:\n%s", want, out.String())
}
}
demo := subcommandByName(t, agent, "demo")
out.Reset()
if err := demo.Action(cli.NewContext(app, nil, nil)); err != nil {
@@ -152,9 +124,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
for _, want := range []string{
"No-secret first-agent demo",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"provider-free",
"stalled-first-agent recovery transcript",
"micro agent preflight # before micro run: prerequisites",
"micro chat",
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
@@ -169,137 +139,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
}
}
func TestFirstAgentDocsMatchCLIOutput(t *testing.T) {
root := filepath.Clean(filepath.Join("..", ".."))
outputs := map[string]string{
"micro docs": commandOutput(t, commandByName(t, "docs")),
"micro examples": commandOutput(t, commandByName(t, "examples")),
"micro zero-to-hero": commandOutput(t, commandByName(t, "zero-to-hero")),
}
agent := commandByName(t, "agent")
outputs["micro agent demo"] = commandOutput(t, subcommandByName(t, agent, "demo"))
outputs["micro agent quickcheck"] = commandOutput(t, subcommandByName(t, agent, "quickcheck"))
contracts := []struct {
name string
file string
markers []string
}{
{
name: "README first-agent on-ramp",
file: filepath.Join(root, "README.md"),
markers: []string{
"micro agent demo",
"micro agent quickcheck",
"micro agent preflight",
"micro agent doctor",
"micro inspect agent <name>",
"micro examples",
"micro zero-to-hero",
"make docs-wayfinding",
"examples/first-agent/",
"examples/support/",
"internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md",
"internal/website/docs/guides/zero-to-hero.md",
},
},
{
name: "website getting-started first-agent on-ramp",
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
markers: []string{
"micro agent demo",
"micro agent quickcheck",
"micro agent preflight",
"micro agent doctor",
"micro inspect agent <name>",
"micro examples",
"micro zero-to-hero",
"make docs-wayfinding",
"github.com/micro/go-micro/tree/master/examples/first-agent",
"github.com/micro/go-micro/tree/master/examples/support",
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"guides/zero-to-hero.html",
},
},
}
for _, contract := range contracts {
doc := readTestFile(t, contract.file)
for _, marker := range contract.markers {
if !strings.Contains(doc, marker) {
t.Fatalf("%s missing documented first-agent marker %q", contract.name, marker)
}
if isCLIContractMarker(marker) && !cliOutputsContain(outputs, marker) {
t.Fatalf("%s documents %q, but none of the first-agent CLI outputs mention it; keep README/website breadcrumbs aligned with micro agent demo/examples/zero-to-hero", contract.name, marker)
}
assertMaintainedFirstAgentPath(t, root, marker)
}
}
}
func commandOutput(t *testing.T, command *cli.Command) string {
t.Helper()
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := command.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("%s failed: %v", command.Name, err)
}
return out.String()
}
func cliOutputsContain(outputs map[string]string, marker string) bool {
for command, out := range outputs {
if command == marker || strings.Contains(out, marker) {
return true
}
}
return false
}
func isCLIContractMarker(marker string) bool {
return strings.HasPrefix(marker, "micro ") || strings.HasPrefix(marker, "go run ") || strings.HasPrefix(marker, "go test ") || strings.Contains(marker, ".html")
}
func assertMaintainedFirstAgentPath(t *testing.T, root, marker string) {
t.Helper()
pathChecks := map[string]string{
"go run ./examples/first-agent": "examples/first-agent",
"examples/first-agent/": "examples/first-agent",
"examples/support/": "examples/support",
"internal/website/docs/guides/no-secret-first-agent.md": "internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md": "internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md": "internal/website/docs/guides/debugging-agents.md",
"internal/website/docs/guides/zero-to-hero.md": "internal/website/docs/guides/zero-to-hero.md",
"guides/no-secret-first-agent.html": "internal/website/docs/guides/no-secret-first-agent.md",
"guides/your-first-agent.html": "internal/website/docs/guides/your-first-agent.md",
"guides/debugging-agents.html": "internal/website/docs/guides/debugging-agents.md",
"guides/zero-to-hero.html": "internal/website/docs/guides/zero-to-hero.md",
"github.com/micro/go-micro/tree/master/examples/first-agent": "examples/first-agent",
"github.com/micro/go-micro/tree/master/examples/support": "examples/support",
}
path, ok := pathChecks[marker]
if !ok {
return
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(path))); err != nil {
t.Fatalf("documented first-agent path %q from marker %q does not resolve: %v", path, marker, err)
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func commandByName(t *testing.T, name string) *cli.Command {
t.Helper()
for _, command := range microcmd.DefaultCmd.App().Commands {
+7 -22
View File
@@ -43,7 +43,7 @@ It reads durable local run history, so it works after the agent or flow has stop
func inspectAgentFlags() []cli.Flag {
return []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run summaries as JSON for automation"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, canceled, timeout, rate_limited, auth, configuration, unavailable, provider_error, error, refused)"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
}
@@ -91,12 +91,6 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
if run.Stage != "" {
fmt.Fprintf(w, " stage=%s", run.Stage)
}
if run.LastErrorKind != "" {
fmt.Fprintf(w, " error_kind=%s", run.LastErrorKind)
}
if run.Spent > 0 {
fmt.Fprintf(w, " spent=%d", run.Spent)
}
if run.LastError != "" {
fmt.Fprintf(w, " error=%q", run.LastError)
}
@@ -104,25 +98,16 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
}
fmt.Fprintln(w)
writeAgentRunBreadcrumbs(w, name, run)
if isResumableAgentRun(run) {
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
}
if run.Stage == "input-required" {
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the paused run\n", run.RunID)
}
}
return nil
}
func writeAgentRunBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
if run.Stage == "input-required" {
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
return
}
if !isResumableAgentRun(run) {
return
}
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
fmt.Fprintf(w, " stream: call micro.ResumeStreamAsk(ctx, agent, %q) to resume with streaming events\n", run.RunID)
}
func isResumableAgentRun(run goagent.RunSummary) bool {
switch run.Status {
case "running", "error", "failed", "refused":
+3 -6
View File
@@ -11,13 +11,13 @@ import (
)
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-1", Status: "auth", Events: 4, LastKind: "model", LastError: "invalid API key", LastErrorKind: "auth", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask", Spent: 7}}
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=auth", "events=4", "last=model", "checkpoint=failed", "stage=ask", "error_kind=auth", `error="invalid API key"`, "trace=1234567890ab", "spent=7"} {
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro.AgentResume(ctx, agent, "run-1")`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
@@ -31,14 +31,11 @@ func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro agent history support run-input`, `micro agent resume-input support run-input --input <text>`} {
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `micro.AgentResume(ctx, agent, "run-input")`) || strings.Contains(got, "ResumeStreamAsk") {
t.Fatalf("input-required run should point at ResumeInput only, got:\n%s", got)
}
}
func TestWriteAgentInspectionEmptyStateNamesInspectCommand(t *testing.T) {
+1 -1
View File
@@ -123,7 +123,7 @@ Examples:
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
&cli.StringFlag{Name: "roles", Usage: "Comma-separated roles, or 'all'", Value: "planner,builder,triage"},
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent any @mention-driven coding agent (e.g. @codex, @claude)", Value: "@codex"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "CI workflow name(s) triage watches for failures (comma-separated)", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
-31
View File
@@ -1,31 +0,0 @@
# Kubernetes deployment foundation (alpha)
This package is the first opt-in Kubernetes foundation for the Go Micro lifecycle:
`Service`, `Agent`, and `Flow` resources. It is intentionally experimental and
additive. Nothing in the Go Micro runtime installs these resources or changes
production defaults.
## What is included
- Alpha CRD manifests in `config/crd/` for `agents.micro.dev`,
`services.micro.dev`, and `flows.micro.dev`.
- A small dependency-free mapper that turns a desired Go Micro resource into the
Kubernetes `Deployment` shape an operator reconciliation loop will own.
- Unit tests that validate the structural CRD fragments and dry-run the
Agent-to-Deployment mapping.
## Local validation
```sh
go test ./deploy/kubernetes
```
If you have a Kubernetes cluster and `kubectl` available, you can also perform a
server-side dry run of the CRDs:
```sh
kubectl apply --dry-run=server -f deploy/kubernetes/config/crd/
```
The manifests are `v1alpha1`; expect the API shape to evolve before this becomes
a production operator.
-37
View File
@@ -1,37 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: agents.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: agents
singular: agent
kind: Agent
shortNames: [magent]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
-37
View File
@@ -1,37 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: flows.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: flows
singular: flow
kind: Flow
shortNames: [mflow]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
-37
View File
@@ -1,37 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: services.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: services
singular: service
kind: Service
shortNames: [mservice]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
-8
View File
@@ -1,8 +0,0 @@
// Package kubernetes contains the experimental Kubernetes deployment foundation
// for Go Micro services, agents, and flows.
//
// The package is intentionally small and additive: it exposes alpha custom
// resource manifests and a dry-run mapper that turns a resource spec into the
// Deployment shape an operator would reconcile. It does not install an operator
// or change any runtime defaults.
package kubernetes
-87
View File
@@ -1,87 +0,0 @@
package kubernetes
import (
"strings"
"testing"
)
func TestCRDManifestsAreStructural(t *testing.T) {
for _, kind := range []Kind{KindAgent, KindService, KindFlow} {
manifest := CRDManifests[kind]
if manifest == "" {
t.Fatalf("missing manifest for %s", kind)
}
checks := []string{
"apiVersion: apiextensions.k8s.io/v1",
"kind: CustomResourceDefinition",
"group: micro.dev",
"kind: " + string(kind),
"name: v1alpha1",
"served: true",
"storage: true",
"openAPIV3Schema:",
"type: object",
"required: [image]",
}
for _, check := range checks {
if !strings.Contains(manifest, check) {
t.Fatalf("%s manifest missing %q:\n%s", kind, check, manifest)
}
}
}
}
func TestMapDeploymentForAgent(t *testing.T) {
deployment, err := MapDeployment(Resource{
Kind: KindAgent,
Name: "support-agent",
Namespace: "agents",
Spec: WorkloadSpec{
Image: "ghcr.io/acme/support-agent:v1",
Replicas: 2,
Registry: "kubernetes",
Environment: map[string]string{
"MODEL": "gpt-5.5",
},
},
})
if err != nil {
t.Fatalf("MapDeployment returned error: %v", err)
}
if deployment.Name != "support-agent" || deployment.Namespace != "agents" {
t.Fatalf("unexpected identity: %+v", deployment)
}
if deployment.Replicas != 2 {
t.Fatalf("replicas = %d, want 2", deployment.Replicas)
}
if got := deployment.Labels["micro.dev/kind"]; got != "agent" {
t.Fatalf("micro.dev/kind label = %q, want agent", got)
}
container := deployment.Pod.Container
if container.Image != "ghcr.io/acme/support-agent:v1" {
t.Fatalf("image = %q", container.Image)
}
if got := container.Environment["MICRO_REGISTRY"]; got != "kubernetes" {
t.Fatalf("MICRO_REGISTRY = %q, want kubernetes", got)
}
if got := container.Environment["MODEL"]; got != "gpt-5.5" {
t.Fatalf("MODEL = %q, want gpt-5.5", got)
}
}
func TestMapDeploymentDefaultsAndValidation(t *testing.T) {
deployment, err := MapDeployment(Resource{Kind: KindService, Name: "api", Spec: WorkloadSpec{Image: "api:latest"}})
if err != nil {
t.Fatalf("MapDeployment returned error: %v", err)
}
if deployment.Namespace != "default" || deployment.Replicas != 1 {
t.Fatalf("defaults = namespace %q replicas %d", deployment.Namespace, deployment.Replicas)
}
if _, err := MapDeployment(Resource{Kind: KindFlow, Name: "ingest"}); err == nil {
t.Fatal("MapDeployment without image succeeded")
}
if _, err := MapDeployment(Resource{Kind: "Job", Name: "job", Spec: WorkloadSpec{Image: "job:latest"}}); err == nil {
t.Fatal("MapDeployment with unsupported kind succeeded")
}
}
-125
View File
@@ -1,125 +0,0 @@
package kubernetes
// CRDManifests contains the alpha CRDs for Go Micro lifecycle resources.
var CRDManifests = map[Kind]string{
KindAgent: agentCRD,
KindService: serviceCRD,
KindFlow: flowCRD,
}
const agentCRD = `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: agents.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: agents
singular: agent
kind: Agent
shortNames: [magent]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
`
const serviceCRD = `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: services.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: services
singular: service
kind: Service
shortNames: [mservice]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
`
const flowCRD = `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: flows.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: flows
singular: flow
kind: Flow
shortNames: [mflow]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
`
-138
View File
@@ -1,138 +0,0 @@
package kubernetes
import (
"fmt"
"sort"
"strings"
)
const (
// Group is the API group for the alpha Go Micro Kubernetes resources.
Group = "micro.dev"
// Version is the current alpha API version for the CRDs in this package.
Version = "v1alpha1"
)
// Kind identifies a Go Micro lifecycle resource that can be reconciled toward a
// Kubernetes Deployment.
type Kind string
const (
KindAgent Kind = "Agent"
KindService Kind = "Service"
KindFlow Kind = "Flow"
)
// WorkloadSpec is the common alpha spec shared by Agent, Service, and Flow CRDs.
type WorkloadSpec struct {
Image string `json:"image"`
Command []string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Replicas int32 `json:"replicas,omitempty"`
Registry string `json:"registry,omitempty"`
Environment map[string]string `json:"env,omitempty"`
}
// Resource is the minimal desired state for a Go Micro lifecycle resource.
type Resource struct {
Kind Kind
Name string
Namespace string
Spec WorkloadSpec
}
// Deployment is a small, dependency-free representation of the Kubernetes
// Deployment fields the alpha reconciler skeleton owns.
type Deployment struct {
Name string
Namespace string
Labels map[string]string
Replicas int32
Pod PodTemplate
}
// PodTemplate describes the pod fields emitted by MapDeployment.
type PodTemplate struct {
Labels map[string]string
Container Container
}
// Container describes the single Go Micro workload container.
type Container struct {
Name string
Image string
Command []string
Args []string
Environment map[string]string
}
// MapDeployment maps a Go Micro alpha resource to the Deployment shape an
// operator reconciliation loop would apply.
func MapDeployment(resource Resource) (Deployment, error) {
if resource.Kind != KindAgent && resource.Kind != KindService && resource.Kind != KindFlow {
return Deployment{}, fmt.Errorf("unsupported kind %q", resource.Kind)
}
name := strings.TrimSpace(resource.Name)
if name == "" {
return Deployment{}, fmt.Errorf("name is required")
}
image := strings.TrimSpace(resource.Spec.Image)
if image == "" {
return Deployment{}, fmt.Errorf("spec.image is required")
}
namespace := strings.TrimSpace(resource.Namespace)
if namespace == "" {
namespace = "default"
}
replicas := resource.Spec.Replicas
if replicas == 0 {
replicas = 1
}
labels := map[string]string{
"app.kubernetes.io/name": name,
"app.kubernetes.io/managed-by": "go-micro",
"micro.dev/kind": strings.ToLower(string(resource.Kind)),
}
env := copyMap(resource.Spec.Environment)
if resource.Spec.Registry != "" {
env["MICRO_REGISTRY"] = resource.Spec.Registry
}
return Deployment{
Name: name,
Namespace: namespace,
Labels: copyMap(labels),
Replicas: replicas,
Pod: PodTemplate{
Labels: copyMap(labels),
Container: Container{
Name: name,
Image: image,
Command: append([]string(nil), resource.Spec.Command...),
Args: append([]string(nil), resource.Spec.Args...),
Environment: env,
},
},
}, nil
}
// EnvironmentKeys returns stable environment variable keys from a mapped
// container. It is useful for deterministic validation and rendering.
func (c Container) EnvironmentKeys() []string {
keys := make([]string, 0, len(c.Environment))
for key := range c.Environment {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func copyMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
+37 -84
View File
@@ -36,9 +36,6 @@ type subscriber struct {
retryLimit int
autoAck bool
ackWait time.Duration
pending []Event
notify chan struct{}
}
type mem struct {
@@ -127,7 +124,6 @@ func (m *mem) Consume(topic string, opts ...ConsumeOption) (<-chan Event, error)
retryMap: map[string]int{},
autoAck: true,
retryLimit: options.GetRetryLimit(),
notify: make(chan struct{}, 1),
}
if !options.AutoAck {
@@ -136,7 +132,6 @@ func (m *mem) Consume(topic string, opts ...ConsumeOption) (<-chan Event, error)
}
sub.autoAck = options.AutoAck
sub.ackWait = options.AckWait
go sub.dispatchManualAck()
}
// register the subscriber
@@ -202,98 +197,56 @@ func (m *mem) handleEvent(ev *Event) {
}
func sendEvent(ev *Event, sub *subscriber) {
evCopy := *ev
if !sub.autoAck {
sub.Lock()
sub.pending = append(sub.pending, evCopy)
sub.Unlock()
sub.wake()
return
}
go func(s *subscriber) {
s.Channel <- evCopy
}(sub)
}
func (s *subscriber) wake() {
select {
case s.notify <- struct{}{}:
default:
}
}
func (s *subscriber) dispatchManualAck() {
for {
s.Lock()
for len(s.pending) == 0 {
s.Unlock()
<-s.notify
s.Lock()
}
ev := s.pending[0]
s.pending = s.pending[1:]
s.retryMap[ev.ID] = 0
s.Unlock()
s.deliverManualAck(ev)
}
}
func (s *subscriber) deliverManualAck(ev Event) {
retries := 0
for {
if s.retryLimit > -1 && retries > s.retryLimit {
if logger.V(logger.ErrorLevel, logger.DefaultLogger) {
logger.Errorf("Message retry limit reached, discarding: %v %d %d", ev.ID, retries, s.retryLimit)
}
s.Lock()
delete(s.retryMap, ev.ID)
s.Unlock()
evCopy := *ev
if s.autoAck {
s.Channel <- evCopy
return
}
result := make(chan bool, 1)
evCopy := ev
evCopy.SetAckFunc(func() error {
select {
case result <- true:
default:
evCopy.SetAckFunc(ackFunc(s, evCopy))
evCopy.SetNackFunc(nackFunc(s, evCopy))
s.Lock()
s.retryMap[evCopy.ID] = 0
s.Unlock()
tick := time.NewTicker(s.ackWait)
defer tick.Stop()
for range tick.C {
s.Lock()
count, ok := s.retryMap[evCopy.ID]
s.Unlock()
if !ok {
// success
break
}
return nil
})
evCopy.SetNackFunc(func() error {
select {
case result <- false:
default:
}
return nil
})
s.Channel <- evCopy
timer := time.NewTimer(s.ackWait)
select {
case acked := <-result:
if !timer.Stop() {
select {
case <-timer.C:
default:
if s.retryLimit > -1 && count > s.retryLimit {
if logger.V(logger.ErrorLevel, logger.DefaultLogger) {
logger.Errorf("Message retry limit reached, discarding: %v %d %d", evCopy.ID, count, s.retryLimit)
}
}
if acked {
s.Lock()
delete(s.retryMap, ev.ID)
delete(s.retryMap, evCopy.ID)
s.Unlock()
return
}
retries++
case <-timer.C:
retries++
s.Channel <- evCopy
s.Lock()
s.retryMap[evCopy.ID] = count + 1
s.Unlock()
}
}(sub)
}
func ackFunc(s *subscriber, evCopy Event) func() error {
return func() error {
s.Lock()
s.retryMap[ev.ID] = retries
delete(s.retryMap, evCopy.ID)
s.Unlock()
return nil
}
}
func nackFunc(_ *subscriber, _ Event) func() error {
return func() error {
return nil
}
}
-8
View File
@@ -157,7 +157,6 @@ func runTestStream(t *testing.T, stream Stream) {
assert.NoError(t, err, "Unexpected error subscribing")
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 1"}))
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 2"}))
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 3"}))
ev := <-ch
ev.Ack()
@@ -171,13 +170,6 @@ func runTestStream(t *testing.T, stream Stream) {
case <-time.After(7 * time.Second):
t.Fatalf("Timed out waiting for message to be put back on queue")
}
select {
case ev = <-ch:
assert.NotEqual(t, ev.ID, nacked, "Queued message should only be received after the nacked message is redelivered")
assert.NoError(t, ev.Ack())
case <-time.After(7 * time.Second):
t.Fatalf("Timed out waiting for queued message")
}
})
+1 -4
View File
@@ -12,7 +12,6 @@ provider-free unless the example README says otherwise.
| 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 a paid tool with x402 | [`agent-x402-buyer`](./agent-x402-buyer/) | `go run ./examples/agent-x402-buyer` | [`Payments (x402)` guide](../internal/website/docs/guides/x402-payments.md) |
| 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/) |
@@ -29,9 +28,7 @@ provider-free unless the example README says otherwise.
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. **Paid tools:** run [`agent-x402-buyer`](./agent-x402-buyer/) to see an
agent pay a local x402-protected tool with a mock facilitator and budget.
6. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
5. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
needs checkpointed, resumable deterministic work.
## CLI wayfinding
-24
View File
@@ -1,24 +0,0 @@
# Agent x402 buyer
This example shows an agent paying for a paid HTTP tool with x402 without using
live funds or a live chain.
It starts a local paid endpoint guarded by `wrapper/x402` seller middleware and a
mock facilitator. A deterministic mock-model agent calls that endpoint as a tool,
receives the HTTP 402 challenge, pays with `AgentPayer`, stays inside
`AgentBudget`, retries the request, and prints the spend recorded for the run.
```bash
go run ./examples/agent-x402-buyer
```
Expected output includes:
- the paid tool response,
- one facilitator verify and settle call, and
- `run spend: 7 smallest units (budget 10)`.
The payment token and facilitator are intentionally local development fakes. To
settle real x402 payments, keep the same `AgentPayer` / `AgentBudget` shape but
replace the payer with a wallet-backed implementation and configure the seller
middleware with a hosted or self-run x402 facilitator.
-168
View File
@@ -1,168 +0,0 @@
// Agent x402 buyer — a provider-free example of an agent paying for a paid tool.
//
// Run:
//
// go run ./examples/agent-x402-buyer
//
// It starts a local HTTP tool protected by x402 middleware, then asks a
// deterministic mock-model agent to call that tool. The agent receives the 402
// challenge, uses AgentPayer and AgentBudget to pay within a local mock
// facilitator, retries the request, and prints the run spend.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
go_micro "go-micro.dev/v6"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
"go-micro.dev/v6/wrapper/x402"
)
const (
paidToolName = "paid.market_brief"
price = int64(7)
paymentToken = "dev-payment-token"
)
type devFacilitator struct {
verifyCount int
settleCount int
}
func (f *devFacilitator) Verify(ctx context.Context, payment string, req x402.Requirements) (x402.Result, error) {
f.verifyCount++
if payment != paymentToken {
return x402.Result{Valid: false, Reason: "unknown dev payment token"}, nil
}
return x402.Result{Valid: true, Payer: "dev-agent-wallet"}, nil
}
func (f *devFacilitator) Settle(ctx context.Context, payment string, req x402.Requirements) (x402.Result, error) {
f.settleCount++
return x402.Result{Valid: true, Settlement: "dev-settlement-001"}, nil
}
type devPayer struct{}
func (devPayer) Pay(ctx context.Context, req x402.Requirements) (string, error) {
return paymentToken, nil
}
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "agent-x402-buyer-mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("stream not supported by agent-x402-buyer mock")
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
for _, tool := range req.Tools {
if tool.Name == paidToolName && m.opts.ToolHandler != nil {
out := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "paid-brief", Name: tool.Name, Input: map[string]any{"url": req.Prompt}})
return &ai.Response{Answer: fmt.Sprintf("Paid tool returned: %s", out.Content)}, nil
}
}
return &ai.Response{Answer: "No paid tool was available."}, nil
}
func paidToolServer(fac *devFacilitator) *httptest.Server {
mux := http.NewServeMux()
paid := x402.Middleware(x402.Config{
PayTo: "0xMerchantDevWallet",
Network: "base-sepolia",
Amount: fmt.Sprint(price),
Description: "Local market brief for the x402 buyer example",
Facilitator: fac,
})
mux.Handle("/brief", paid(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"brief": "Mock demand is up 12% after the agent paid the local tool.",
"settlement": w.Header().Get(x402.PaymentResponseHeader),
})
})))
return httptest.NewServer(mux)
}
func run(w io.Writer) error {
ai.Register("agent-x402-buyer-mock", newMock)
fac := &devFacilitator{}
srv := paidToolServer(fac)
defer srv.Close()
st := store.NewMemoryStore()
buyer := agent.New(
agent.Name("x402-buyer"),
agent.Provider("agent-x402-buyer-mock"),
agent.Prompt("Call the paid market brief tool when given its URL."),
agent.WithStore(st),
go_micro.AgentPayer(devPayer{}),
go_micro.AgentBudget(10),
agent.WithTool(paidToolName, "Fetch a paid market brief over HTTP", map[string]any{
"url": map[string]any{"type": "string", "description": "Paid HTTP endpoint to call"},
}, func(ctx context.Context, input map[string]any) (string, error) {
url, _ := input["url"].(string)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}),
)
resp, err := buyer.Ask(context.Background(), srv.URL+"/brief")
if err != nil {
return err
}
events, err := agent.LoadRunEvents(st, "x402-buyer", resp.RunID)
if err != nil {
return err
}
var spent int64
for _, event := range events {
if event.Spent > spent {
spent = event.Spent
}
}
fmt.Fprintln(w, "Agent x402 buyer (provider: mock, funds: local dev token)")
fmt.Fprintln(w, strings.TrimSpace(resp.Reply))
fmt.Fprintf(w, "facilitator verify=%d settle=%d\n", fac.verifyCount, fac.settleCount)
fmt.Fprintf(w, "run spend: %d smallest units (budget 10)\n", spent)
return nil
}
func main() {
if err := run(os.Stdout); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
+2 -19
View File
@@ -34,22 +34,5 @@ CI keeps this path runnable with:
go test ./examples/first-agent
```
## Next chat, inspect, and debug breadcrumbs
This example exits after one in-process `assistant.Ask` call so it stays tiny and
provider-free. When you move from this transcript to a long-running agent, keep
these commands nearby:
```bash
micro run
micro chat assistant --prompt "Summarize my next steps"
micro inspect agent assistant
micro agent doctor assistant
```
Use the [no-secret first-agent guide](../../internal/website/docs/guides/no-secret-first-agent.md)
to compare this transcript with the CLI demo, then keep the
[debugging guide](../../internal/website/docs/guides/debugging-agents.md) open for
preflight, doctor, inspect, and history checks. After that, continue to
[`examples/support`](../support/) for the full services → agents → workflows
lifecycle with a flow trigger and an approval gate.
After this, continue to [`examples/support`](../support/) for the full services →
agents → workflows lifecycle with a flow trigger and an approval gate.
+7 -12
View File
@@ -13,7 +13,6 @@ package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
@@ -34,13 +33,13 @@ type ListNotesResponse struct {
Notes []string `json:"notes" description:"Notes the assistant can summarize"`
}
type NotesService struct{ w io.Writer }
type NotesService struct{}
// List returns the starter notes the first agent can read.
// @example {}
func (s *NotesService) List(ctx context.Context, req *ListNotesRequest, rsp *ListNotesResponse) error {
rsp.Notes = []string{"Install the micro CLI", "Run a service", "Chat with an agent"}
fmt.Fprintln(s.w, " [notes] listed starter notes")
fmt.Println(" [notes] listed starter notes")
return nil
}
@@ -91,10 +90,6 @@ func waitFor(reg registry.Registry, names ...string) error {
}
func runFirstAgent() error {
return runFirstAgentWithWriter(os.Stdout)
}
func runFirstAgentWithWriter(w io.Writer) error {
ai.Register("first-agent-mock", newMock)
reg := registry.NewMemoryRegistry()
@@ -109,7 +104,7 @@ func runFirstAgentWithWriter(w io.Writer) error {
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))), client.Broker(br))
notes := service.New(service.Name("notes"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl), service.Broker(br), service.HandleSignal(false))
if err := notes.Handle(&NotesService{w: w}); err != nil {
if err := notes.Handle(new(NotesService)); err != nil {
return fmt.Errorf("handle notes: %w", err)
}
svcErr := make(chan error, 1)
@@ -142,14 +137,14 @@ func runFirstAgentWithWriter(w io.Writer) error {
return err
}
fmt.Fprintln(w, "First agent (provider: mock, no API key)")
fmt.Fprintln(w, "> Summarize my next steps")
fmt.Println("First agent (provider: mock, no API key)")
fmt.Println("> Summarize my next steps")
resp, err := assistant.Ask(context.Background(), "Summarize my next steps")
if err != nil {
return fmt.Errorf("ask assistant: %w", err)
}
fmt.Fprintln(w, "assistant:", resp.Reply)
fmt.Fprintln(w, "✓ service-backed agent completed without provider secrets")
fmt.Println("assistant:", resp.Reply)
fmt.Println("✓ service-backed agent completed without provider secrets")
return nil
}
+2 -64
View File
@@ -1,71 +1,9 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
import "testing"
func TestRunFirstAgent(t *testing.T) {
var out bytes.Buffer
if err := runFirstAgentWithWriter(&out); err != nil {
if err := runFirstAgent(); err != nil {
t.Fatalf("first-agent example failed: %v", err)
}
want := strings.TrimSpace(readExpectedTranscript(t))
got := strings.TrimSpace(out.String())
if got != want {
t.Fatalf("first-agent transcript drifted from README.md\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestReadmeDocumentsNextBreadcrumbs(t *testing.T) {
b, err := os.ReadFile("README.md")
if err != nil {
t.Fatalf("read README.md: %v", err)
}
readme := string(b)
start := strings.Index(readme, "## Next chat, inspect, and debug breadcrumbs")
if start < 0 {
t.Fatal("README.md missing next chat, inspect, and debug breadcrumbs section")
}
section := readme[start:]
for _, want := range []string{
"micro run",
"micro chat assistant --prompt \"Summarize my next steps\"",
"micro inspect agent assistant",
"micro agent doctor assistant",
"no-secret-first-agent.md",
"debugging-agents.md",
"examples/support",
} {
if !strings.Contains(section, want) {
t.Fatalf("README.md next breadcrumbs missing %q", want)
}
}
}
func readExpectedTranscript(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("README.md")
if err != nil {
t.Fatalf("read README.md: %v", err)
}
readme := string(b)
const fence = "```text"
start := strings.Index(readme, "Expected transcript:")
if start < 0 {
t.Fatal("README.md missing Expected transcript section")
}
fenceStart := strings.Index(readme[start:], fence)
if fenceStart < 0 {
t.Fatal("README.md missing transcript text fence")
}
start += fenceStart + len(fence)
end := strings.Index(readme[start:], "```")
if end < 0 {
t.Fatal("README.md missing closing transcript fence")
}
return readme[start : start+end]
}
-26
View File
@@ -54,32 +54,6 @@ agent, which:
emailing a customer (`notify.Send`) passes through the gate first. Return
`false` to hold it for a person or a policy; the example approves and logs.
## Expected inspect transcript
The provider-free run prints the same visible checkpoints a new developer should
compare against after chat and flow execution. The transcript includes service
tool calls, the approval gate, and the inspect/run-history commands that prove
the workflow run was recorded.
```text
> event: events.ticket.created {"customer":"alice@acme.com","id":"ticket-1","subject":"Can't log in"}
[customers] looked up Alice (pro plan)
[tickets] ticket-1 → priority=high status=in_progress
▣ approval gate notify_NotifyService_Send(alice@acme.com) — approved
[notify] 📨 to=alice@acme.com: "Hi Alice — thanks for reaching out. We've bumped this to high priority and are on it."
support agent: Triaged ticket-1 for Alice and sent a reply.
inspect transcript:
micro inspect flow intake
flow: intake runs=1 latest.reply="Triaged ticket-1 for Alice and sent a reply."
micro agent history support
agent: support runs=1 latest.status=completed
✓ ticket triaged and the customer was replied to — triggered by an event
```
## Run
```bash
+1 -7
View File
@@ -302,13 +302,7 @@ func runSupport(provider string) error {
}
if rs := intake.Results(); len(rs) > 0 {
latest := rs[len(rs)-1]
fmt.Printf("\n\033[1msupport agent:\033[0m %s\n", latest.Reply)
fmt.Println("\n\033[1minspect transcript:\033[0m")
fmt.Println(" micro inspect flow intake")
fmt.Printf(" flow: intake runs=%d latest.reply=%q\n", len(rs), latest.Reply)
fmt.Println(" micro agent history support")
fmt.Printf(" agent: support runs=%d latest.status=completed\n", len(rs))
fmt.Printf("\n\033[1msupport agent:\033[0m %s\n", rs[len(rs)-1].Reply)
}
if notify.sent >= 1 {
fmt.Println("\n\033[32m✓ ticket triaged and the customer was replied to — triggered by an event\033[0m")
-78
View File
@@ -1,10 +1,7 @@
package main
import (
"bytes"
"io"
"os"
"regexp"
"strings"
"testing"
)
@@ -33,78 +30,3 @@ func TestZeroToHeroReadmeDocumentsLifecycle(t *testing.T) {
}
}
}
func TestZeroToHeroInspectTranscript(t *testing.T) {
out := captureStdout(t, func() {
if err := runSupport("mock"); err != nil {
t.Fatalf("support example failed: %v", err)
}
})
got := stripANSI(out)
for _, want := range []string{
`> event: events.ticket.created {"customer":"alice@acme.com","id":"ticket-1","subject":"Can't log in"}`,
`[customers] looked up Alice (pro plan)`,
`[tickets] ticket-1 → priority=high status=in_progress`,
`approval gate notify_NotifyService_Send(alice@acme.com) — approved`,
`[notify] 📨 to=alice@acme.com: "Hi Alice — thanks for reaching out. We've bumped this to high priority and are on it."`,
`support agent: Triaged ticket-1 for Alice and sent a reply.`,
`inspect transcript:`,
`micro inspect flow intake`,
`flow: intake runs=1 latest.reply="Triaged ticket-1 for Alice and sent a reply."`,
`micro agent history support`,
`agent: support runs=1 latest.status=completed`,
`✓ ticket triaged and the customer was replied to — triggered by an event`,
} {
if !strings.Contains(got, want) {
t.Fatalf("support transcript missing %q\n--- got ---\n%s", want, got)
}
}
readme, err := os.ReadFile("README.md")
if err != nil {
t.Fatalf("read README.md: %v", err)
}
for _, want := range []string{
"Expected inspect transcript",
"micro inspect flow intake",
"micro agent history support",
"agent: support runs=1 latest.status=completed",
} {
if !strings.Contains(string(readme), want) {
t.Fatalf("README.md missing transcript contract %q", want)
}
}
}
func captureStdout(t *testing.T, fn func()) (out string) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("capture stdout: %v", err)
}
os.Stdout = w
var buf bytes.Buffer
done := make(chan struct{})
go func() {
_, _ = io.Copy(&buf, r)
close(done)
}()
defer func() {
_ = w.Close()
os.Stdout = old
<-done
out = buf.String()
}()
fn()
return out
}
var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(s string) string {
return ansiRE.ReplaceAllString(s, "")
}
+5 -35
View File
@@ -627,9 +627,6 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
reply = err.Error()
state = stateInputRequired
}
} else if strings.TrimSpace(reply) == "" {
reply = "error: agent returned an empty response"
state = stateFailed
}
task := d.taskFromReply(p.Message, reply, state)
d.store(task)
@@ -753,11 +750,13 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
if err := g.opts.Client.Call(ctx, req, &rsp); err != nil {
return "", err
}
reply, err := decodeAgentChatReply(rsp.Data)
if err != nil {
var out struct {
Reply string `json:"reply"`
}
if err := json.Unmarshal(rsp.Data, &out); err != nil {
return "", err
}
return reply, nil
return out.Reply, nil
}
// ---------------------------------------------------------------------------
@@ -977,35 +976,6 @@ func textArtifact(text string) Artifact {
}
}
func decodeAgentChatReply(data []byte) (string, error) {
var out struct {
Reply string `json:"reply"`
Answer string `json:"answer"`
Content string `json:"content"`
Text string `json:"text"`
Message struct {
Content string `json:"content"`
Text string `json:"text"`
} `json:"message"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", err
}
for _, candidate := range []string{
out.Reply,
out.Answer,
out.Content,
out.Text,
out.Message.Content,
out.Message.Text,
} {
if strings.TrimSpace(candidate) != "" {
return candidate, nil
}
}
return "", nil
}
// requestContext carries request cancellation and deadlines into the downstream
// agent call without leaking HTTP transport context values into the go-micro
// client stack.
-59
View File
@@ -523,65 +523,6 @@ func TestMessageStreamChunksFallsBackWhenUnsupported(t *testing.T) {
}
}
func TestMessageStreamFallbackDoesNotCompleteWithEmptyText(t *testing.T) {
d := newDispatcher()
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
rr := httptest.NewRecorder()
d.serveWithStream(rr, req, func(context.Context, string) (string, error) {
return "", nil
}, func(context.Context, string) (ai.Stream, error) {
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
})
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
}
if event.Error != nil {
t.Fatalf("fallback event error: %+v", event.Error)
}
if event.Result.Status.State != stateFailed {
t.Fatalf("fallback state = %q, want failed", event.Result.Status.State)
}
if got := textOf(event.Result.Artifacts[0].Parts); got == "" {
t.Fatalf("fallback artifact text is empty: %+v", event.Result.Artifacts)
}
if got := textOf(event.Result.History[len(event.Result.History)-1].Parts); got == "" {
t.Fatalf("fallback history text is empty: %+v", event.Result.History)
}
}
func TestDecodeAgentChatReplyFallsBackToProviderTextFields(t *testing.T) {
for name, body := range map[string]string{
"answer": `{"answer":"answer text"}`,
"content": `{"content":"content text"}`,
"text": `{"text":"text field"}`,
"message_content": `{"message":{"content":"message content"}}`,
"message_text": `{"message":{"text":"message text"}}`,
} {
t.Run(name, func(t *testing.T) {
got, err := decodeAgentChatReply([]byte(body))
if err != nil {
t.Fatalf("decodeAgentChatReply error: %v", err)
}
if strings.TrimSpace(got) == "" {
t.Fatalf("decodeAgentChatReply(%s) returned empty text", body)
}
})
}
}
func TestTasksResubscribeStreamsCurrentAndSubsequentEvents(t *testing.T) {
d := newDispatcher()
initial := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateWorking, Timestamp: time.Now().UTC().Format(time.RFC3339)}}
-282
View File
@@ -1,282 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protodesc"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/dynamicpb"
)
// ReflectedGRPCTarget describes an external gRPC server whose reflection
// catalog should be exposed as MCP tools. It is intentionally opt-in: teams can
// bridge existing reflected gRPC services without changing their servers or
// registering them in go-micro.
type ReflectedGRPCTarget struct {
// Name prefixes generated tools. When empty, Address is sanitized and used.
Name string
// Address is the host:port of the reflected gRPC server.
Address string
// DialOptions customize the connection. If none are supplied, an insecure
// transport is used for local/dev interoperability.
DialOptions []grpc.DialOption
// Timeout bounds reflection discovery and individual tool calls.
Timeout time.Duration
}
func (s *Server) discoverReflectedGRPC() error {
for _, target := range s.opts.ReflectedGRPCTargets {
if strings.TrimSpace(target.Address) == "" {
continue
}
tools, err := s.reflectedGRPCTools(target)
if err != nil {
return err
}
for _, tool := range tools {
s.tools[tool.Name] = tool
}
}
return nil
}
func (s *Server) reflectedGRPCTools(target ReflectedGRPCTarget) ([]*Tool, error) {
timeout := target.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(s.opts.Context, timeout)
defer cancel()
dialOpts := target.DialOptions
if len(dialOpts) == 0 {
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
conn, err := grpc.NewClient(target.Address, dialOpts...)
if err != nil {
return nil, fmt.Errorf("connect reflected grpc target %s: %w", target.Address, err)
}
defer conn.Close()
files, services, err := loadReflectedFiles(ctx, conn)
if err != nil {
return nil, fmt.Errorf("reflect grpc target %s: %w", target.Address, err)
}
prefix := target.Name
if prefix == "" {
prefix = sanitizeToolPart(target.Address)
}
var out []*Tool
for _, serviceName := range services {
desc, err := files.FindDescriptorByName(protoreflect.FullName(serviceName))
if err != nil {
continue
}
svc, ok := desc.(protoreflect.ServiceDescriptor)
if !ok {
continue
}
for i := 0; i < svc.Methods().Len(); i++ {
method := svc.Methods().Get(i)
if method.IsStreamingClient() || method.IsStreamingServer() {
continue
}
fullMethod := "/" + string(svc.FullName()) + "/" + string(method.Name())
toolName := prefix + "." + strings.ReplaceAll(string(svc.FullName()), ".", "_") + "." + string(method.Name())
input := method.Input()
out = append(out, &Tool{
Name: toolName,
Description: fmt.Sprintf("Call reflected gRPC method %s on %s", fullMethod, target.Address),
InputSchema: protoMessageSchema(input),
Handler: reflectedGRPCHandler(target, fullMethod, input, method.Output()),
})
}
}
return out, nil
}
func loadReflectedFiles(ctx context.Context, conn *grpc.ClientConn) (*protoregistryFiles, []string, error) {
client := reflectionpb.NewServerReflectionClient(conn)
stream, err := client.ServerReflectionInfo(ctx)
if err != nil {
return nil, nil, err
}
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_ListServices{ListServices: ""}}); err != nil {
return nil, nil, err
}
resp, err := stream.Recv()
if err != nil {
return nil, nil, err
}
list := resp.GetListServicesResponse()
if list == nil {
return nil, nil, fmt.Errorf("reflection list services returned %T", resp.MessageResponse)
}
set := &descriptorpb.FileDescriptorSet{}
seen := map[string]bool{}
var services []string
for _, svc := range list.Service {
name := svc.Name
if strings.HasPrefix(name, "grpc.reflection.") {
continue
}
services = append(services, name)
if err := requestFileContainingSymbol(ctx, client, name, set, seen); err != nil {
return nil, nil, err
}
}
files, err := newProtoregistryFiles(set)
if err != nil {
return nil, nil, err
}
return files, services, nil
}
func requestFileContainingSymbol(ctx context.Context, client reflectionpb.ServerReflectionClient, symbol string, set *descriptorpb.FileDescriptorSet, seen map[string]bool) error {
stream, err := client.ServerReflectionInfo(ctx)
if err != nil {
return err
}
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{FileContainingSymbol: symbol}}); err != nil {
return err
}
resp, err := stream.Recv()
if err != nil {
return err
}
fd := resp.GetFileDescriptorResponse()
if fd == nil {
return fmt.Errorf("reflection lookup for %s returned %T", symbol, resp.MessageResponse)
}
for _, raw := range fd.FileDescriptorProto {
var file descriptorpb.FileDescriptorProto
if err := proto.Unmarshal(raw, &file); err != nil {
return err
}
name := file.GetName()
if !seen[name] {
seen[name] = true
set.File = append(set.File, &file)
}
}
return nil
}
// protoregistryFiles is a narrow wrapper that keeps imports local to this file.
type protoregistryFiles struct{ files *protoregistry.Files }
func newProtoregistryFiles(set *descriptorpb.FileDescriptorSet) (*protoregistryFiles, error) {
files, err := protodesc.NewFiles(set)
if err != nil {
return nil, err
}
return &protoregistryFiles{files: files}, nil
}
func (p *protoregistryFiles) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error) {
return p.files.FindDescriptorByName(name)
}
func reflectedGRPCHandler(target ReflectedGRPCTarget, fullMethod string, input, output protoreflect.MessageDescriptor) func(map[string]interface{}) (interface{}, error) {
return func(args map[string]interface{}) (interface{}, error) {
timeout := target.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
dialOpts := target.DialOptions
if len(dialOpts) == 0 {
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
conn, err := grpc.NewClient(target.Address, dialOpts...)
if err != nil {
return nil, err
}
defer conn.Close()
req := dynamicpb.NewMessage(input)
raw, err := json.Marshal(args)
if err != nil {
return nil, err
}
if err := protojson.Unmarshal(raw, req); err != nil {
return nil, err
}
rsp := dynamicpb.NewMessage(output)
if err := conn.Invoke(ctx, fullMethod, req, rsp); err != nil {
return nil, err
}
b, err := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}.Marshal(rsp)
if err != nil {
return nil, err
}
var out interface{}
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out, nil
}
}
func protoMessageSchema(msg protoreflect.MessageDescriptor) map[string]interface{} {
schema := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
props := schema["properties"].(map[string]interface{})
fields := msg.Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
props[field.JSONName()] = protoFieldSchema(field)
}
return schema
}
func protoFieldSchema(field protoreflect.FieldDescriptor) map[string]interface{} {
schema := map[string]interface{}{"type": protoJSONType(field)}
if field.IsList() {
schema["items"] = map[string]interface{}{"type": protoJSONType(field)}
}
if field.Kind() == protoreflect.MessageKind || field.Kind() == protoreflect.GroupKind {
schema = protoMessageSchema(field.Message())
}
return schema
}
func protoJSONType(field protoreflect.FieldDescriptor) string {
if field.IsList() {
return "array"
}
switch field.Kind() {
case protoreflect.BoolKind:
return "boolean"
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind,
protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Int64Kind,
protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, protoreflect.Uint64Kind,
protoreflect.Fixed64Kind:
return "integer"
case protoreflect.FloatKind, protoreflect.DoubleKind:
return "number"
case protoreflect.MessageKind, protoreflect.GroupKind:
return "object"
default:
return "string"
}
}
func sanitizeToolPart(s string) string {
r := strings.NewReplacer(":", "_", "/", "_", ".", "_", "-", "_")
return r.Replace(s)
}
-62
View File
@@ -1,62 +0,0 @@
package mcp
import (
"context"
"net"
"testing"
"time"
"google.golang.org/grpc"
helloworld "google.golang.org/grpc/examples/helloworld/helloworld"
"google.golang.org/grpc/reflection"
)
type reflectedGreeter struct {
helloworld.UnimplementedGreeterServer
}
func (reflectedGreeter) SayHello(_ context.Context, req *helloworld.HelloRequest) (*helloworld.HelloReply, error) {
return &helloworld.HelloReply{Message: "hello " + req.Name}, nil
}
func TestReflectedGRPCTargetDiscoversAndCallsUnaryTool(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
grpcServer := grpc.NewServer()
helloworld.RegisterGreeterServer(grpcServer, reflectedGreeter{})
reflection.Register(grpcServer)
go grpcServer.Serve(lis)
defer grpcServer.Stop()
s := newTestServer(Options{Context: context.Background()})
tools, err := s.reflectedGRPCTools(ReflectedGRPCTarget{
Name: "demo",
Address: lis.Addr().String(),
Timeout: 3 * time.Second,
})
if err != nil {
t.Fatalf("discover reflected tools: %v", err)
}
if len(tools) != 1 {
t.Fatalf("tools len = %d, want 1", len(tools))
}
tool := tools[0]
if tool.Name != "demo.helloworld_Greeter.SayHello" {
t.Fatalf("tool name = %q", tool.Name)
}
props := tool.InputSchema["properties"].(map[string]interface{})
if _, ok := props["name"]; !ok {
t.Fatalf("input schema missing name: %#v", tool.InputSchema)
}
out, err := tool.Handler(map[string]interface{}{"name": "Ada"})
if err != nil {
t.Fatalf("call reflected tool: %v", err)
}
got := out.(map[string]interface{})["message"]
if got != "hello Ada" {
t.Fatalf("message = %v, want hello Ada", got)
}
}
-9
View File
@@ -157,11 +157,6 @@ type Options struct {
// (the /mcp/call endpoint). Listing tools and health stay free.
// Opt-in: leave nil to disable payments.
Payment *x402.Config
// ReflectedGRPCTargets exposes unary methods from external gRPC servers
// that support server reflection as MCP tools. This bridges existing gRPC
// services into the agent tool catalog without requiring go-micro handlers.
ReflectedGRPCTargets []ReflectedGRPCTarget
}
// Server represents a running MCP gateway
@@ -291,10 +286,6 @@ func (s *Server) discoverServices() error {
s.toolsMu.Lock()
defer s.toolsMu.Unlock()
if err := s.discoverReflectedGRPC(); err != nil {
return err
}
for _, svc := range services {
// Get full service details
fullSvcs, err := s.opts.Registry.GetService(svc.Name)
+19 -5
View File
@@ -294,9 +294,7 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
AccountID: accountID, ScopesRequired: tool.Scopes,
Allowed: true, Duration: time.Since(start), Error: err.Error(),
})
// A tool-execution failure is reported as an isError result, not a
// JSON-RPC protocol error (per the MCP spec), so the agent can read it.
t.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
t.sendError(req.ID, InternalError, "RPC call failed", err.Error())
return
}
@@ -313,8 +311,24 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
Allowed: true, Duration: time.Since(start),
})
// The downstream response is JSON — return it as JSON text, not %v.
t.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
// Parse response
var result interface{}
if err := json.Unmarshal(rsp.Data, &result); err != nil {
// If unmarshal fails, return raw data
result = map[string]interface{}{
"data": string(rsp.Data),
}
}
t.sendResponse(req.ID, map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": fmt.Sprintf("%v", result),
},
},
"trace_id": traceID,
})
}
// sendResponse sends a JSON-RPC response
-120
View File
@@ -1,120 +0,0 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"go-micro.dev/v6/client"
)
// fakeCallClient overrides Call to return canned data or an error; NewRequest
// and the rest are promoted from the embedded real client.
type fakeCallClient struct {
client.Client
data []byte
err error
}
func (f *fakeCallClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
if f.err != nil {
return f.err
}
if r, ok := rsp.(*struct{ Data []byte }); ok {
r.Data = f.data
}
return nil
}
// isToolError reports whether an MCP tools/call result carries isError:true.
func isToolError(result interface{}) bool {
m, ok := result.(map[string]interface{})
if !ok {
return false
}
b, _ := m["isError"].(bool)
return b
}
// toolResultText extracts the first text content of an MCP tools/call result.
func toolResultText(t *testing.T, result interface{}) string {
t.Helper()
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("result is not a map: %#v", result)
}
content, ok := m["content"].([]interface{})
if !ok || len(content) == 0 {
t.Fatalf("result has no content: %#v", result)
}
first, _ := content[0].(map[string]interface{})
text, _ := first["text"].(string)
return text
}
// driveStdio sends one JSON-RPC request through a StdioTransport and returns the
// decoded response, capturing the transport's stdout into a buffer.
func driveStdio(t *testing.T, s *Server, method string, id interface{}, params interface{}) JSONRPCResponse {
t.Helper()
tr := NewStdioTransport(s)
var out bytes.Buffer
tr.writer = bufio.NewWriter(&out)
raw, _ := json.Marshal(params)
tr.handleRequest(&JSONRPCRequest{JSONRPC: "2.0", ID: id, Method: method, Params: raw})
var resp JSONRPCResponse
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp); err != nil {
t.Fatalf("decode stdio response: %v (raw=%q)", err, out.String())
}
return resp
}
// The stdio transport is the path an external MCP host (Claude Desktop) uses.
// It must return tool output as JSON text, not fmt.Sprintf("%v", ...) which
// yields Go map-syntax and is unparseable by a real client.
func TestStdio_ToolsCall_ReturnsJSONNotGoSyntax(t *testing.T) {
s := newTestServer(Options{})
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, data: []byte(`{"id":1,"name":"bob"}`)}
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{"msg": "hi"},
})
if resp.Error != nil {
t.Fatalf("unexpected protocol error: %+v", resp.Error)
}
text := toolResultText(t, resp.Result)
// The bug returned Go map-syntax ("map[id:1 name:bob]"), which fails to parse.
var got map[string]interface{}
if err := json.Unmarshal([]byte(text), &got); err != nil {
t.Fatalf("tool result text is not JSON (the %%v bug): %q", text)
}
if got["name"] != "bob" {
t.Errorf("result = %v, want name=bob", got)
}
}
// A tool-execution failure must be an MCP isError result, not a JSON-RPC
// protocol error, so the agent can read the failure.
func TestStdio_ToolsCall_FailureIsIsErrorResult(t *testing.T) {
s := newTestServer(Options{})
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, err: errors.New("backend down")}
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{},
})
if resp.Error != nil {
t.Fatalf("tool failure returned a protocol error, want isError result: %+v", resp.Error)
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError result, got %+v", resp.Result)
}
if text := toolResultText(t, resp.Result); text == "" {
t.Error("isError result should carry the error text")
}
}
-32
View File
@@ -1,32 +0,0 @@
package mcp
// MCP tools/call result shaping, shared by the stdio and websocket JSON-RPC
// transports. Kept in one place so both transports produce spec-shaped results.
// mcpToolResult builds a successful MCP tools/call result. The downstream RPC
// response body (data) is JSON, so it is returned as JSON text — NOT
// fmt.Sprintf("%v", ...) of a decoded value, which produces Go map-syntax
// (map[id:1 name:bob]) instead of JSON and is what an external MCP client
// (e.g. Claude Desktop over stdio) would otherwise receive.
func mcpToolResult(traceID string, data []byte) map[string]interface{} {
return map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": string(data)},
},
"trace_id": traceID,
}
}
// mcpToolError builds an MCP tools/call result for a tool-EXECUTION failure.
// Per the MCP spec a tool that fails returns a normal result with isError:true
// (the error as text content), NOT a JSON-RPC protocol error — that way the
// agent can read the failure instead of seeing a transport-level error.
func mcpToolError(traceID, msg string) map[string]interface{} {
return map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": msg},
},
"isError": true,
"trace_id": traceID,
}
}
+18 -4
View File
@@ -275,8 +275,7 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
AccountID: accountID, ScopesRequired: tool.Scopes,
Allowed: true, Duration: time.Since(start), Error: err.Error(),
})
// Tool-execution failure → isError result (MCP spec), not a protocol error.
wc.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
wc.sendError(req.ID, InternalError, "RPC call failed", err.Error())
return
}
@@ -292,8 +291,23 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
Allowed: true, Duration: time.Since(start),
})
// The downstream response is JSON — return it as JSON text, not %v.
wc.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
// Parse response
var result interface{}
if err := json.Unmarshal(rsp.Data, &result); err != nil {
result = map[string]interface{}{
"data": string(rsp.Data),
}
}
wc.sendResponse(req.ID, map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": fmt.Sprintf("%v", result),
},
},
"trace_id": traceID,
})
}
// sendResponse sends a JSON-RPC success response.
+15 -18
View File
@@ -114,13 +114,12 @@ func TestWebSocket_ToolsCall_NoAuth(t *testing.T) {
"arguments": map[string]interface{}{"msg": "hi"},
})
// No auth required → the tool runs; the RPC fails (no backend), which the
// MCP spec surfaces as an isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error, got %+v", resp.Error)
// RPC will fail (no backend), but auth should pass (no auth configured)
if resp.Error == nil {
t.Fatal("expected RPC error (no backend)")
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d", resp.Error.Code, InternalError)
}
}
@@ -169,13 +168,12 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
"arguments": map[string]interface{}{},
"_token": "valid-token",
})
// Auth passes → the tool runs; RPC fails (no backend) → isError result,
// not a JSON-RPC protocol error (which would mean auth failed).
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
// Auth passes, RPC fails (no backend)
if resp.Error == nil {
t.Fatal("expected RPC error")
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
}
})
@@ -187,13 +185,12 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
"name": "svc.Do",
"arguments": map[string]interface{}{},
})
// Auth passes via connection-level header → tool runs; RPC fails (no
// backend) → isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
// Auth passes via connection-level header, RPC fails (no backend)
if resp.Error == nil {
t.Fatal("expected RPC error")
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
}
})
}
+29 -24
View File
@@ -1,8 +1,8 @@
module go-micro.dev/v6
go 1.25.0
go 1.24
toolchain go1.25.12
toolchain go1.24.1
require (
dario.cat/mergo v1.0.2
@@ -17,7 +17,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/consul/api v1.32.1
github.com/jackc/pgx/v5 v5.10.0
github.com/jackc/pgx/v4 v4.18.3
github.com/kr/pretty v0.3.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.34
@@ -32,23 +32,24 @@ require (
github.com/prometheus/client_model v0.6.1
github.com/rabbitmq/amqp091-go v1.10.0
github.com/stretchr/objx v0.5.2
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.10.0
github.com/test-go/testify v1.1.4
github.com/urfave/cli/v2 v2.27.6
github.com/xlab/treeprint v1.2.0
go.etcd.io/bbolt v1.4.0
go.etcd.io/etcd/api/v3 v3.5.21
go.etcd.io/etcd/client/v3 v3.5.21
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.79.3
golang.org/x/crypto v0.37.0
golang.org/x/net v0.38.0
golang.org/x/sync v0.13.0
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463
google.golang.org/grpc v1.71.1
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057
google.golang.org/protobuf v1.36.10
google.golang.org/protobuf v1.36.6
)
require (
@@ -62,7 +63,7 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
@@ -74,9 +75,14 @@ require (
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jackc/puddle v1.3.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -91,20 +97,19 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+160 -48
View File
@@ -2,7 +2,9 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -29,14 +31,19 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cornelk/hashmap v1.0.8 h1:nv0AWgw02n+iDcawr5It4CjQIAcdMMKRrs10HOJYlrc=
github.com/cornelk/hashmap v1.0.8/go.mod h1:RfZb7JO3RviW/rT6emczVuC/oxpdz4UsSB2LJSclR1k=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -53,11 +60,13 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
@@ -66,6 +75,8 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -86,6 +97,7 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -134,14 +146,55 @@ github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR
github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@@ -150,17 +203,24 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -168,6 +228,8 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@@ -253,27 +315,38 @@ github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2b
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@@ -286,6 +359,7 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8=
@@ -294,42 +368,64 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoB
go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs=
go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY=
go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
@@ -337,23 +433,25 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -366,6 +464,7 @@ golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -374,46 +473,58 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057 h1:lPv+iqlAyiKMjbL3ivJlAASixPknLv806R6zaoE4PUM=
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057/go.mod h1:WPWnet+nYurNGpV0rVYHI1YuOJwVHeM3t8f76m410XM=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -425,3 +536,4 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+3 -20
View File
@@ -3,11 +3,8 @@
`go test ./...` includes `TestAgentProviderConformanceMatrix`, a shared agent
scenario that runs against every registered chat provider. The scenario asks an
agent to call a deterministic local tool, verifies the tool receives `ai.RunInfo`,
and checks the final response carries the conformance marker. The live matrix
includes MiniMax in the tool/guardrail path in addition to providers with
streaming coverage, so every supported chat provider has at least one key-gated
agent contract. A fake provider path runs on every machine without network
access so CI always exercises the harness.
and checks the final response carries the conformance marker. A fake provider path
runs on every machine without network access so CI always exercises the harness.
Live providers are opt-in to avoid flaky unauthenticated PR checks and accidental
API spend. To run the live matrix, set `GO_MICRO_AGENT_CONFORMANCE_LIVE=1` plus the
@@ -20,7 +17,6 @@ provider API keys you want to exercise:
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
| MiniMax | `MINIMAX_API_KEY` | `GO_MICRO_CONFORMANCE_MINIMAX_MODEL` |
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
@@ -43,20 +39,7 @@ live model credits.
Use `make provider-conformance` when you want the live-provider sweep: providers
without keys are skipped, and configured providers must satisfy the same harness
contract. For the exact scheduled command, run:
```sh
go run ./internal/harness/provider-conformance \
-providers anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud \
-harnesses agent,universe,agent-flow,plan-delegate,a2a-stream-fallback \
-summary-json provider-conformance-summary.json \
-summary-markdown provider-conformance-summary.md \
-capabilities-markdown provider-capabilities.md
```
The generated summary records one row for every selected provider/harness pair;
missing live-provider keys become skipped rows for each harness, while configured
providers produce pass/fail rows per harness.
contract.
## Scheduled CI
+2 -2
View File
@@ -21,8 +21,8 @@ Actions instead of subagents. Each role is a workflow:
|------|------------------------|--------------|
| **Planner** | `loop-planner.yml`*Loop: Planner* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). Decides *what*. |
| **Generator** | `loop-builder.yml`*Loop: Builder (Generator)* | Builds the top open queue item as a single-concern PR (via Codex) and self-merges on green CI. Does the work. |
| **Evaluator** | `harness.yml`*Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`, `govulncheck.yml`) | Grades every change: the mock harness + unit/lint + reachable-CVE scan on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, govulncheck, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Evaluator** | `harness.yml`*Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Coherence** | `loop-coherence.yml`*Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
| **Security** | `loop-security.yml`*Loop: Security* | Weekly vulnerability audit of the attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps via `govulncheck`). Files `security` issues; **never auto-merges** fixes and **never publishes exploit detail** in public issues (responsible disclosure); risky fixes are `needs-human`. |
| **Release** | `loop-release.yml`*Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
-259
View File
@@ -1,259 +0,0 @@
// A2A streaming harness.
//
// It exercises the default, no-secret agent streaming path across the
// services → agents → A2A boundary: an A2A message/stream request invokes an
// agent StreamAsk turn, the agent executes a tool, and the gateway emits
// working SSE task updates before the completed final answer.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if req.Prompt == "" {
return nil, errors.New("missing prompt")
}
if len(req.Tools) == 0 || m.opts.ToolHandler == nil {
return nil, errors.New("missing tools or tool handler")
}
res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}})
if res.Content == "" {
return nil, errors.New("empty tool result")
}
return &ai.Response{
Reply: "streaming completed",
Answer: res.Content,
ToolCalls: []ai.ToolCall{{
ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}, Result: res.Content,
}},
}, nil
}
type agentStreamAdapter struct{ stream agent.AgentStream }
func (s agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
if err != nil {
return nil, err
}
if event == nil {
continue
}
switch event.Type {
case agent.StreamEventToken:
if event.Token != "" {
return &ai.Response{Reply: event.Token}, nil
}
case agent.StreamEventDone:
return nil, io.EOF
}
}
}
func (s agentStreamAdapter) Close() error { return s.stream.Close() }
func main() {
provider := flag.String("provider", "mock", "LLM provider; mock is deterministic and requires no API key")
flag.Parse()
if *provider == "mock" {
ai.Register("mock", newMock)
}
fmt.Printf("\n\033[1mA2A streaming conformance (provider: %s)\033[0m\n", *provider)
reg := registry.NewMemoryRegistry()
st := store.NewMemoryStore()
var sawTool, sawRunInfo bool
ag := agent.New(append([]agent.Option{
agent.Name("a2a-streaming"),
agent.Provider(*provider),
agent.Prompt("Use stream_echo exactly once with value a2a-stream, then answer with the tool result."),
agent.WithRegistry(reg),
agent.WithStore(st),
agent.WithMemory(agent.NewInMemory(8)),
agent.ModelCallTimeout(45 * time.Second),
agent.WithTool("stream_echo", "Echo the A2A stream marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok || info.RunID == "" || info.Agent != "a2a-streaming" {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "a2a-stream" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"a2a-stream-ok"}`, nil
}),
}, harnessutil.AgentOptions(*provider)...)...)
handler := a2a.NewAgentStreamHandler(
a2a.Card("a2a-streaming", "http://example.invalid/a2a-streaming", "", nil),
func(ctx context.Context, text string) (string, error) {
resp, err := ag.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
},
func(ctx context.Context, text string) (ai.Stream, error) {
stream, err := agent.StreamAsk(ctx, ag, text)
if err != nil {
return nil, err
}
return agentStreamAdapter{stream: stream}, nil
},
)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A streaming conformance check."}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b)
os.Exit(1)
}
if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
os.Exit(1)
}
summary, err := readSSESummary(res.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if summary.WorkingEvents == 0 || summary.State != "completed" || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
fmt.Fprintf(os.Stderr, "unexpected stream summary: %+v\npayload:\n%s", summary, summary.Payload)
os.Exit(1)
}
if !sawTool || !sawRunInfo {
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ A2A message/stream emitted incremental task updates and preserved tool/run metadata\033[0m")
}
type streamSummary struct {
Payload string
State string
FinalText string
WorkingEvents int
}
func readSSESummary(r io.Reader) (streamSummary, error) {
scanner := bufio.NewScanner(r)
var event strings.Builder
var summary streamSummary
seen := false
flush := func() error {
data := strings.TrimSpace(event.String())
event.Reset()
if data == "" {
return nil
}
var envelope struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal([]byte(data), &envelope); err != nil {
return fmt.Errorf("SSE data event is not JSON: %s", data)
}
if envelope.Error != nil {
return fmt.Errorf("SSE data event has error: %s", data)
}
seen = true
summary.Payload += data + "\n"
if envelope.Result.Status.State == "working" {
summary.WorkingEvents++
}
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
for _, artifact := range envelope.Result.Artifacts {
for _, part := range artifact.Parts {
summary.FinalText = part.Text
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
if err := flush(); err != nil {
return streamSummary{}, err
}
continue
}
data, ok := strings.CutPrefix(line, "data:")
if !ok {
continue
}
if event.Len() > 0 {
event.WriteByte('\n')
}
event.WriteString(strings.TrimSpace(data))
}
if err := scanner.Err(); err != nil {
return streamSummary{}, err
}
if err := flush(); err != nil {
return streamSummary{}, err
}
if !seen {
return streamSummary{}, errors.New("no SSE data received")
}
return summary, nil
}
+7 -42
View File
@@ -49,31 +49,19 @@ type CreateResponse struct {
}
type WorkspaceService struct {
mu sync.Mutex
n int
byOwner map[string]*Workspace
mu sync.Mutex
n int
}
// Create provisions a workspace for a new user.
// @example {"owner": "alice@acme.com"}
func (s *WorkspaceService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
s.mu.Lock()
if s.byOwner == nil {
s.byOwner = make(map[string]*Workspace)
}
if ws, ok := s.byOwner[req.Owner]; ok {
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m duplicate suppressed %s for %s\n", ws.ID, req.Owner)
rsp.Workspace = ws
return nil
}
s.n++
id := fmt.Sprintf("ws-%d", s.n)
ws := &Workspace{ID: id, Owner: req.Owner}
s.byOwner[req.Owner] = ws
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m created %s for %s\n", id, req.Owner)
rsp.Workspace = ws
rsp.Workspace = &Workspace{ID: id, Owner: req.Owner}
return nil
}
@@ -91,26 +79,14 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
n int
sent map[string]bool
mu sync.Mutex
n int
}
// Send delivers a notification message to a recipient.
// @example {"to": "alice@acme.com", "message": "Welcome"}
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
key := strings.ToLower(strings.TrimSpace(req.To))
s.mu.Lock()
if s.sent == nil {
s.sent = make(map[string]bool)
}
if s.sent[key] {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m duplicate suppressed to=%s message=%q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
s.sent[key] = true
s.n++
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
@@ -202,22 +178,15 @@ func waitFor(reg registry.Registry, name string) {
}
}
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
recovered := false
for {
workspaces, notifications := wsSvc.count(), ntSvc.count()
if workspaces >= 1 && notifications >= 1 {
return nil
}
if workspaces >= 1 && notifications == 0 && !recovered && recoverMissingNotify != nil {
recovered = true
if err := recoverMissingNotify(ctx); err != nil {
return fmt.Errorf("agent-flow created workspace but failed to recover missing onboarding notification: workspaces=%d/1 notifications=%d/1: %w", workspaces, notifications, err)
}
}
select {
case <-ctx.Done():
@@ -311,11 +280,7 @@ func main() {
// 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, func(ctx context.Context) error {
fmt.Print("\n\033[33mwarning:\033[0m workspace exists before notify; retrying the welcome notification once before the flow can complete.\n")
_, err := onboarder.Ask(ctx, "The workspace for alice@acme.com already exists. Send exactly one welcome notification to alice@acme.com now. Use the notify service. Do not create another workspace and do not answer until the notification tool call has succeeded.")
return err
})
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
cancel()
fmt.Printf("\n\033[1mresult:\033[0m workspaces created=%d, notifications sent=%d\n", wsSvc.count(), ntSvc.count())
+2 -76
View File
@@ -109,7 +109,7 @@ func TestWaitForOnboardingSideEffectsFailsWhenMissing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil)
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
if err == nil {
t.Fatal("waitForOnboardingSideEffects returned nil, want missing side effects error")
}
@@ -131,81 +131,7 @@ func TestWaitForOnboardingSideEffectsPassesWhenComplete(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil); err != nil {
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc); err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want nil", err)
}
}
func TestWaitForOnboardingSideEffectsRecoversMissingNotification(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)
}
recovered := false
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, func(ctx context.Context) error {
recovered = true
return ntSvc.Send(ctx, &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{})
})
if err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want recovered notification", err)
}
if !recovered {
t.Fatal("missing notification recovery did not run")
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after recovery", got)
}
}
func TestWorkspaceCreateSuppressesDuplicateOwner(t *testing.T) {
wsSvc := new(WorkspaceService)
first := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, first); err != nil {
t.Fatalf("create first workspace: %v", err)
}
second := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, second); err != nil {
t.Fatalf("create duplicate workspace: %v", err)
}
if got := wsSvc.count(); got != 1 {
t.Fatalf("workspace creations = %d, want 1 after duplicate owner replay", got)
}
if first.Workspace == nil || second.Workspace == nil || second.Workspace.ID != first.Workspace.ID {
t.Fatalf("duplicate create returned workspace %#v, want original %#v", second.Workspace, first.Workspace)
}
}
func TestNotifySendSuppressesDuplicateMessage(t *testing.T) {
ntSvc := new(NotifyService)
req := &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send first notification: %v", err)
}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send duplicate notification: %v", err)
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after duplicate message replay", got)
}
}
func TestNotifySendSuppressesDuplicateRecipient(t *testing.T) {
ntSvc := new(NotifyService)
if err := ntSvc.Send(context.Background(), &SendRequest{To: "Alice@Acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{}); err != nil {
t.Fatalf("send first notification: %v", err)
}
if err := ntSvc.Send(context.Background(), &SendRequest{To: " alice@acme.com ", Message: "Your workspace is ready."}, &SendResponse{}); err != nil {
t.Fatalf("send duplicate recipient notification: %v", err)
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after duplicate recipient replay", got)
}
}

Some files were not shown because too many files have changed in this diff Show More