Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 467129ffc4 agent: trace tool retry attempts
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-07 09:58:41 +00:00
174 changed files with 1028 additions and 10075 deletions
+20 -33
View File
@@ -1,41 +1,28 @@
# 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. **Emit OpenTelemetry spans from agent run history** ([#4218](https://github.com/micro/go-micro/issues/4218)) — streaming coverage shipped via #4226, so close the next agentic-depth operability seam called out by the roadmap and blog: `RunInfo`/history and production traces should tell the same story for model steps, tool calls, retries, delegation, and failures, making `micro inspect` and deployed traces feel like one debugging path.
2. **Add examples wayfinding index for first-agent adoption** ([#4223](https://github.com/micro/go-micro/issues/4223)) — keep developer adoption weighted with internal hardening: the README and getting-started guide now have a stronger first-agent path, but examples remain spread across docs, CLI output, and directories. A single CI-guarded examples map should make the smallest no-secret agent, the 0→hero support app, and next interop examples discoverable from one place.
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
+2 -5
View File
@@ -14,11 +14,8 @@ name: "Loop: Builder"
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "29 * * * *"
schedule:
- cron: "29 * * * *"
permissions:
issues: write
+2 -5
View File
@@ -14,11 +14,8 @@ name: "Loop: Coherence"
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "0 7 * * *"
schedule:
- cron: "0 7 * * *"
permissions:
issues: write
+2 -5
View File
@@ -14,11 +14,8 @@ name: "Loop: Planner"
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "59 * * * *"
schedule:
- cron: "59 * * * *"
permissions:
issues: write
+9 -33
View File
@@ -1,22 +1,17 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next tag when the default branch has
# new commits since the latest one, and pushes it with a PAT (CODEX_TRIGGER_TOKEN)
# so any tag-triggered release workflow fires. The bump reflects what shipped,
# read from the CHANGELOG [Unreleased] section: new features (Added/Changed) cut
# a MINOR; fixes/docs only cut a PATCH; breaking changes are skipped so a MAJOR
# stays a human decision.
# Generated by `micro loop init`. Cuts the next PATCH tag
# (vMAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (CODEX_TRIGGER_TOKEN) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic nightly release disabled while the team does
# focused 1:1 fixes. Cut a release on demand via workflow_dispatch. Re-enable
# by uncommenting the schedule below.
# schedule:
# - cron: "0 23 * * *"
schedule:
- cron: "0 23 * * *"
permissions:
contents: read
@@ -71,30 +66,11 @@ jobs:
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
# Choose the bump from what actually shipped, read from the CHANGELOG
# [Unreleased] section (kept current by the coherence role):
# new features (### Added / ### Changed) -> MINOR
# fixes/docs only -> PATCH
# breaking (### Removed / "(breaking)") -> skip; a major is a human call
UNRELEASED=""
if [ -f CHANGELOG.md ]; then
UNRELEASED=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
fi
if printf '%s\n' "$UNRELEASED" | grep -qiE '^### Removed|^### Changed \(breaking\)|BREAKING'; then
echo "CHANGELOG [Unreleased] contains breaking changes — a major release is a human decision. Skipping."
exit 0
elif printf '%s\n' "$UNRELEASED" | grep -qE '^### (Added|Changed)'; then
NEXT="v${major}.$((minor + 1)).0"
KIND="minor (new features)"
else
NEXT="v${major}.${minor}.$((patch + 1))"
KIND="patch (fixes/docs only)"
fi
echo "cutting: $NEXT — $KIND ($COUNT commits since $LATEST)"
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
+2 -5
View File
@@ -14,11 +14,8 @@ name: "Loop: Security"
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "0 6 * * 1"
schedule:
- cron: "0 6 * * 1"
permissions:
issues: write
+3 -7
View File
@@ -7,13 +7,9 @@ name: "Loop: Triage"
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
on:
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic CI-failure dispatch disabled while the team
# does focused 1:1 fixes, so failures don't auto-spawn agent tasks. Re-enable
# by uncommenting the workflow_run trigger below.
# workflow_run:
# workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
# types: [completed]
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
types: [completed]
permissions:
issues: write
+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
+3 -164
View File
@@ -5,10 +5,9 @@ All notable changes to Go Micro are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
follow [Semantic Versioning](https://semver.org/), matching the git tags and
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
Releases are cut automatically as the loop merges improvements — a **minor**
bump when new features land (`### Added`/`### Changed`), a **patch** when it's
fixes/docs only; major bumps stay a human decision. The `[Unreleased]` section
below is kept current between tags and rolled into the next version when it ships.
Patch releases are cut automatically as the loop merges improvements; the
`[Unreleased]` section below is kept current between tags and rolled into the
next version when it ships.
> Earlier `2026.0x` headings are historical calendar-style markers from before
> v6 tagging; they are kept for continuity and not reused.
@@ -17,172 +16,12 @@ 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/`)
- **A2A inbound AP2 mandate verification (opt-in)** — set `Options.AP2PublicKey` (or `a2a.WithPushURLPolicy`'s sibling `a2a.WithAP2PublicKey` for embedded handlers) and the gateway verifies AP2 payment/checkout mandates carried on incoming messages — signature and task/context binding — recording the outcome in each task's `ap2Verifications`, with the x402 settlement rail carried through for the paid path. Off by default; mandates are otherwise carried unverified. (`gateway/a2a/`)
- **Flow human-in-the-loop pause/resume** — a flow step can suspend a run for external input with `flow.Await(key, prompt)` (or `flow.AwaitStep`): the run checkpoints with status `waiting` and `Execute` returns cleanly. `Flow.Waiting` lists suspended runs with what they await, and `Flow.ResumeWith(ctx, runID, input)` injects the input and continues from the next step. Recovery (`ResumePending`) skips waiting runs since they need input, not a restart. (`flow/`)
- **Kubernetes reconcile core (alpha)** — `kubernetes.Reconcile(desired, observed)` decides the single action needed to converge an `Agent`/`Service`/`Flow` resource toward its Deployment (create / update / noop) and returns `Ready`/`Error` status conditions. Dependency-free (no controller-runtime / client-go) and fully unit-testable; a future operator binary supplies observed state and applies the action. (`deploy/kubernetes/`)
- **In-process "local network" fast-path (opt-in)** — `client.Local()` lets a unary `Call` to a service running in the same process skip the network transport and dispatch straight to that server's handlers (for raw `codec/bytes.Frame` bodies — the shape agent/MCP/flow tool calls use), running the same router, wrappers, and codecs. In a benchmark this cut an in-process call from ~545µs to ~28µs (≈20×) with ~3.6× fewer allocations. Off by default; falls back to the network path for anything it doesn't cover. (`client/`, `server/`, `internal/network/`)
- **`micro.Local()` service option** — turn on the in-process fast-path for a whole service in one place: every co-located unary call its client makes (agent tool calls, flow dispatch, gateway → service) takes the fast-path, with no per-call wiring. Off by default; a no-op for distributed deployments. (`service/`, root `options.go`)
### Changed
- **Remote agent chat streaming** — `micro chat` now streams replies from remote agents instead of waiting for the full response. (`cmd/micro/`, `agent/`)
- **A2A external-client conformance** — the A2A gateway now serves the Agent Card at the spec 0.3.0 `/.well-known/agent-card.json` (keeping `/.well-known/agent.json` as a legacy alias), and `message/stream` emits spec-shaped `status-update`/`artifact-update` events ending in a `final:true` status-update instead of repeated full `Task` snapshots — and never sends `result` and `error` together. Standard A2A clients (ADK, LangGraph, a2a-SDK) can now discover and stream from go-micro agents. (`gateway/a2a/`)
### 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/`)
- **A2A push-notification SSRF guard** — the A2A gateway no longer delivers task push notifications to caller-supplied URLs that resolve to loopback, private, link-local (incl. cloud metadata), or unspecified addresses. Callbacks are validated when set and re-checked at dial time on the resolved IP (DNS-rebinding safe); non-http(s) schemes are rejected. `Options.AllowPushURL` (and `a2a.WithPushURLPolicy` for embedded handlers) lets operators authorize trusted in-cluster receivers. (`gateway/a2a/`)
---
## [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/`)
---
## [6.3.18] - July 2026
### Added
- **StreamAsk close cancellation** — agent streaming calls now cancel promptly when their runner closes, avoiding orphaned stream work. (`agent/`)
- **Agent resume pending helper** — agent durability now has a focused helper for resuming pending checkpointed runs. (`agent/`)
- **Agent tool retry tracing** — agent traces now include tool retry attempts for easier debugging of retry/fallback behavior. (`agent/`)
- **Shared-broker universe harness** — the universe harness now runs against the shared broker path, improving coverage of the same runtime wiring used by services, agents, and workflows. (`internal/harness/`)
### Fixed
- **Plan/delegate retry idempotency** — agent retries now preserve side-effect and notification dedupe across conformance retry paths, including completion and owner-notification edge cases. (`agent/`, `internal/harness/`)
- **AtlasCloud text tool calls** — AtlasCloud fallback handling now recovers more text-rendered tool calls from OpenAI-compatible responses. (`ai/atlascloud/`, `agent/`)
- **OpenAI-compatible text tool calls** — OpenAI-compatible providers now recover text-rendered tool calls more reliably. (`agent/`)
- **AtlasCloud multi-step follow-ups** — AtlasCloud tool fallback handling now continues multi-step tool follow-up paths more reliably. (`ai/atlascloud/`, `agent/`)
### Documentation
- **Agent debugging quickcheck** — docs now include a focused quickcheck path for first-agent debugging. (`internal/website/docs/`)
- **Website first-agent examples map** — website docs now link the maintained examples wayfinding map for the first-agent route. (`internal/website/docs/`)
- **Examples wayfinding index** — examples docs now provide a central map for first-agent, support, and interop examples. (`examples/`, `internal/website/docs/`)
---
+5 -39
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 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,6 @@ 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 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"
@@ -53,43 +49,12 @@ test-coverage:
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) cli-wayfinding
$(MAKE) inner-loop
$(MAKE) zero-to-hero-transcript
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Verify the 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.
cli-wayfinding:
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
$(MAKE) docs-wayfinding
$(MAKE) install-smoke
# Verify the README and website first-agent/0→hero wayfinding links resolve to
# maintained local docs and examples. This is a focused no-network guard for the
# developer-adoption on-ramp.
docs-wayfinding:
go test ./internal/harness/zero-to-hero-ci -run 'TestFirstAgentWayfinding' -count=1
go test ./cmd/micro -run 'TestFirstAgentDocsMatchCLIOutput|TestFirstAgentWalkthroughCLIBoundaries' -count=1
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
@@ -145,3 +110,4 @@ gorelease-dry-run:
-w /$(NAME) \
$(GORELEASER_DOCKER_IMAGE) \
--clean --verbose --skip=publish,validate --snapshot
+14 -34
View File
@@ -1,8 +1,9 @@
# 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.
## Overview
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
@@ -17,10 +18,6 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
## Community
Questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
## Commercial Support
Running Go Micro in production, or building on it and want help? Paid **support, consulting, training, and retainers** are available directly from the maintainer — and they're what keep the project maintained. See [**Support**](SUPPORT.md) for the tiers, or [open a request](https://github.com/micro/go-micro/issues/new?template=commercial_support.md).
@@ -81,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
@@ -105,24 +91,18 @@ 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. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
6. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
9. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
7. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
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
8. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro inspect agent <name>`, run history, memory, and provider checks when the first
conversation does something unexpected.
9. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
+20 -45
View File
@@ -14,9 +14,8 @@ The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micr
## Where we are (v6)
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware,
checkpoint/resume, and OpenTelemetry run spans), durable flows, the MCP and A2A
gateways (both directions, including A2A streaming,
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
flows, the MCP and A2A gateways (both directions, including A2A streaming,
push notifications, and multi-turn continuation), x402 paid tools, secure by
default.
@@ -32,55 +31,31 @@ 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.
## Next — agentic depth
## Next — reach & deployment
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
- **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.
## Later
## Later — exploratory
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
- **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).
## Developer experience (ongoing)
## Ongoing — hardening & DX (background, not the headline)
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.
- 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
+21 -65
View File
@@ -68,79 +68,35 @@ func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
}
// The spec-shaped stream carries the answer as append artifact-update
// deltas and closes with a completed status-update (final:true).
var (
text strings.Builder
finalState string
sawFinal bool
)
var final 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"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
var ev struct {
Result json.RawMessage `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal([]byte(line), &ev); err != nil {
if err := json.Unmarshal([]byte(line), &final); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
if ev.Error != nil {
t.Fatalf("event carried an error field: %+v", ev.Error)
}
var kind struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(ev.Result, &kind)
switch kind.Kind {
case "artifact-update":
var au struct {
Artifact struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifact"`
}
_ = json.Unmarshal(ev.Result, &au)
for _, p := range au.Artifact.Parts {
text.WriteString(p.Text)
}
case "status-update":
var su struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Final bool `json:"final"`
}
_ = json.Unmarshal(ev.Result, &su)
if su.Final {
sawFinal = true
finalState = su.Status.State
}
default: // opening "task" snapshot
var task struct {
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
}
_ = json.Unmarshal(ev.Result, &task)
for _, a := range task.Artifacts {
for _, p := range a.Parts {
if p.Text != "" {
text.WriteString(p.Text)
}
}
}
}
}
if !sawFinal || finalState != "completed" {
t.Fatalf("want a completed final:true status-update; sawFinal=%v state=%q", sawFinal, finalState)
if final.Error != nil {
t.Fatalf("final event error: %+v", final.Error)
}
if !strings.Contains(text.String(), "a2a-stream-ok") {
t.Fatalf("reassembled stream text missing tool marker: %q", text.String())
if final.Result.Status.State != "completed" {
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
}
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
}
}
+5 -95
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
@@ -104,17 +99,6 @@ type agentImpl struct {
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
// delegateCalls collapses concurrent equivalent delegate tool calls so a
// provider replay cannot fan out duplicate delegated side effects before the
// durable delegate-result cache is written.
delegateMu sync.Mutex
delegateCalls map[string]*delegateCall
// 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 +214,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,61 +221,19 @@ 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})
a.mem.Add("user", message)
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
Messages: a.mem.Messages(),
})
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 +290,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 +338,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 +349,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
}
@@ -473,15 +406,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
continue
}
}
if toolName := partialTextToolCallName(resp.Reply, toolList); len(resp.ToolCalls) == 0 && toolName != "" && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
message = 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)
a.mem.Add("user", message)
messages = a.mem.Messages()
continue
}
break
}
@@ -500,13 +424,9 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
reply += resp.Answer
}
completedToolCalls := checkpointToolCalls(run.Steps)
if a.currentRun != nil {
completedToolCalls = checkpointToolCalls(a.currentRun.Steps)
}
res := &Response{
Reply: reply,
ToolCalls: mergeCheckpointToolCalls(completedToolCalls, resp.ToolCalls),
ToolCalls: resp.ToolCalls,
Agent: a.opts.Name,
RunID: a.runID,
ParentID: parentRunID,
@@ -598,11 +518,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 +539,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
+6 -157
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
@@ -30,11 +27,6 @@ const (
toolHumanInput = "request_input"
)
type delegateCall struct {
done chan struct{}
res ai.ToolResult
}
// builtinTools returns the tool definitions exposed to the model in
// addition to the agent's scoped service tools.
func builtinTools() []ai.Tool {
@@ -131,11 +123,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 +167,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 +291,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 +367,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 {
@@ -682,7 +585,7 @@ func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
// if 'to' names a registered agent, it is called via RPC. Otherwise an
// ephemeral sub-agent is created with a fresh, isolated context, asked
// the subtask, and its reply returned.
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
input := call.Input
task, _ := input["task"].(string)
if task == "" {
@@ -693,12 +596,6 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
return cached
}
key := delegateResultKey(to, task)
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
return cached
}
defer func() { a.finishDelegateCall(key, res) }()
// An external agent on another framework, addressed by A2A URL.
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
reply, err := a2a.NewClient(to).Send(ctx, task)
@@ -750,38 +647,6 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
}
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
a.delegateMu.Lock()
if a.delegateCalls == nil {
a.delegateCalls = map[string]*delegateCall{}
}
if inFlight := a.delegateCalls[key]; inFlight != nil {
a.delegateMu.Unlock()
select {
case <-ctx.Done():
return errResult(id, ctx.Err().Error()), true
case <-inFlight.done:
return withToolResultID(inFlight.res, id), true
}
}
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
a.delegateMu.Unlock()
return ai.ToolResult{}, false
}
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
a.delegateMu.Lock()
inFlight := a.delegateCalls[key]
if inFlight == nil {
a.delegateMu.Unlock()
return
}
inFlight.res = res
delete(a.delegateCalls, key)
close(inFlight.done)
a.delegateMu.Unlock()
}
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
recs, err := a.stateStore().Read(delegateResultKey(to, task))
if err != nil || len(recs) == 0 {
@@ -801,11 +666,6 @@ func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any)
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
}
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
res.ID = id
return res
}
func delegateResultKey(to, task string) string {
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
sum := sha256.Sum256([]byte(fp))
@@ -829,28 +689,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 {
+8 -53
View File
@@ -3,9 +3,7 @@ package agent
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
@@ -196,59 +194,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)
}
}
func TestDelegateInFlightReplaysShareFirstResult(t *testing.T) {
a := New(Name("planner"), WithStore(store.NewMemoryStore())).(*agentImpl)
key := delegateResultKey("comms", "Notify owner@acme.com that the launch plan is ready")
if _, joined := a.joinDelegateCall(context.Background(), "delegate-1", key); joined {
t.Fatal("first delegate call unexpectedly joined an existing in-flight call")
}
var wg sync.WaitGroup
wg.Add(1)
results := make(chan ai.ToolResult, 1)
go func() {
defer wg.Done()
res, joined := a.joinDelegateCall(context.Background(), "delegate-2", key)
if !joined {
t.Error("replayed delegate call did not join the in-flight call")
return
}
results <- res
}()
select {
case res := <-results:
t.Fatalf("replayed delegate returned before first call finished: %+v", res)
case <-time.After(25 * time.Millisecond):
}
first := ai.ToolResult{ID: "delegate-1", Content: `{"reply":"Notified owner@acme.com."}`}
a.finishDelegateCall(key, first)
wg.Wait()
replayed := <-results
if replayed.ID != "delegate-2" {
t.Fatalf("replayed result ID = %q, want delegate-2", replayed.ID)
}
if replayed.Content != first.Content {
t.Fatalf("replayed content = %q, want %q", replayed.Content, first.Content)
if !containsStr(cached.Content, "Notified owner@acme.com") {
t.Fatalf("cached result content = %q, want original delegate reply", cached.Content)
}
}
+12 -79
View File
@@ -3,9 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
@@ -216,14 +214,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 +234,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
}
}
@@ -302,56 +288,3 @@ func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
*steps = append(*steps, rec)
return len(*steps) - 1
}
func checkpointToolCalls(steps []flow.StepRecord) []ai.ToolCall {
calls := make([]ai.ToolCall, 0, len(steps))
for _, step := range steps {
call, ok := checkpointToolCall(step)
if !ok {
continue
}
calls = append(calls, call)
}
return calls
}
func checkpointToolCall(step flow.StepRecord) (ai.ToolCall, bool) {
if step.Status != "done" || !strings.HasPrefix(step.Name, "tool:") {
return ai.ToolCall{}, false
}
parts := strings.SplitN(strings.TrimPrefix(step.Name, "tool:"), ":", 2)
if len(parts) != 2 || parts[0] == "" {
return ai.ToolCall{}, false
}
input := map[string]any{}
if parts[1] != "null" && parts[1] != "" {
if err := json.Unmarshal([]byte(parts[1]), &input); err != nil {
return ai.ToolCall{}, false
}
}
return ai.ToolCall{Name: parts[0], Input: input, Result: step.Result}, true
}
func mergeCheckpointToolCalls(checkpointed, current []ai.ToolCall) []ai.ToolCall {
if len(checkpointed) == 0 {
return current
}
seen := make(map[string]struct{}, len(current))
for _, call := range current {
seen[toolCallKey(call.Name, call.Input)] = struct{}{}
}
merged := make([]ai.ToolCall, 0, len(checkpointed)+len(current))
for _, call := range checkpointed {
if _, ok := seen[toolCallKey(call.Name, call.Input)]; ok {
continue
}
merged = append(merged, call)
}
merged = append(merged, current...)
return merged
}
func toolCallKey(name string, input map[string]any) string {
b, _ := json.Marshal(input)
return name + ":" + string(b)
}
+1 -114
View File
@@ -21,7 +21,7 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "external.lookup", Result: "cached"}}}, nil
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
@@ -49,9 +49,6 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
if resumed.RunID != resp.RunID {
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
}
if len(resumed.ToolCalls) != 1 || resumed.ToolCalls[0].Name != "external.lookup" || resumed.ToolCalls[0].Result != "cached" {
t.Fatalf("resumed tool calls = %#v, want persisted completed call", resumed.ToolCalls)
}
if calls != 1 {
t.Fatalf("model calls after Resume = %d, want 1", calls)
}
@@ -104,12 +101,6 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
if resp.Reply != "finished from checkpoint" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "external.charge" || resp.ToolCalls[0].Result != "charged" {
t.Fatalf("resumed tool calls = %#v, want preserved completed charge call", resp.ToolCalls)
}
if got := resp.ToolCalls[0].Input["order"]; got != "42" {
t.Fatalf("resumed tool input order = %#v, want 42", got)
}
if toolRuns != 1 {
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
}
@@ -154,45 +145,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")
@@ -445,71 +397,6 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
}
}
func TestResumePendingAfterFreshAgentRestartDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "startup-resume-agent")
toolRuns := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.allocate", Input: map[string]any{"cluster": "blue"}})
if res.Content != "allocated" {
t.Fatalf("tool result = %q, want allocated", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped before final response")
}
return &ai.Response{Reply: "startup recovery complete"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("startup-resume-agent"), WithStore(st), WithCheckpoint(cp),
WithTool("external.allocate", "allocate capacity once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "allocated", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "allocate blue capacity")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
restarted := newAgent()
failedRun, err := ResumePending(ctx, restarted)
if err != nil {
t.Fatalf("ResumePending after restart: failedRun=%q err=%v", failedRun, err)
}
if failedRun != "" {
t.Fatalf("failed run = %q, want none", failedRun)
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumePending = %d, want completed tool not replayed", toolRuns)
}
runs, err := Pending(ctx, restarted)
if err != nil {
t.Fatalf("Pending after ResumePending: %v", err)
}
if len(runs) != 0 {
t.Fatalf("Pending after ResumePending = %#v, want none", runs)
}
summaries, err := ListRunSummaries(st, "startup-resume-agent")
if err != nil {
t.Fatalf("ListRunSummaries after ResumePending: %v", err)
}
if len(summaries) != 1 || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" {
t.Fatalf("summary after ResumePending = %#v, want one done run", summaries)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
+11 -229
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
@@ -22,20 +21,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 {
@@ -49,151 +45,6 @@ func TestAgentProviderConformanceMatrix(t *testing.T) {
}
}
func TestAgentProviderStreamConformanceMatrix(t *testing.T) {
providers := streamConformanceProviders()
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
provider := provider
if len(selected) > 0 && !selected[provider.name] {
continue
}
t.Run(provider.name, func(t *testing.T) {
runAgentStreamConformanceScenario(t, provider)
})
}
}
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 {
if os.Getenv(provider.key) == "" {
t.Skipf("%s not set; skipping live %s stream conformance", provider.key, provider.name)
}
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 {
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) {
if req.Prompt != "Stream exactly: agent-stream-conformance-ok" {
return nil, fmt.Errorf("prompt = %q", req.Prompt)
}
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != req.Prompt {
return nil, fmt.Errorf("messages = %#v, want current user turn", req.Messages)
}
for _, tool := range req.Tools {
if tool.Name == "conformance_echo" {
sawToolSchema = true
}
}
if !sawToolSchema {
return nil, errors.New("stream request omitted conformance tool schema")
}
return &sliceStream{chunks: []string{"agent-stream-", "conformance-ok"}}, nil
}
defer func() { fakeStream = nil }()
}
agentOpts := []Option{
Name("stream-conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt("Stream conformance: preserve the exact requested marker in the final answer."),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(8)),
ModelCallTimeout(45 * time.Second),
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
return `{"marker":"agent-stream-conformance-ok"}`, nil
}),
}
if provider.model != "" {
if model := os.Getenv(provider.model); model != "" {
agentOpts = append(agentOpts, Model(model))
}
}
stream, err := New(agentOpts...).Stream(context.Background(), "Stream exactly: agent-stream-conformance-ok")
if err != nil {
t.Fatalf("Stream: %v", err)
}
defer stream.Close()
var reply strings.Builder
deadline := time.After(45 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for streamed final output")
default:
}
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
reply.WriteString(chunk.Reply)
if strings.Contains(reply.String(), "agent-stream-conformance-ok") {
return
}
}
if got := reply.String(); !strings.Contains(got, "agent-stream-conformance-ok") {
t.Fatalf("streamed reply %q does not include conformance marker", got)
}
}
func selectedConformanceProviders(csv string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(csv, ",") {
@@ -436,20 +287,6 @@ func TestAgentProviderConformanceFinalDelegateRetryUsesTaggedCall(t *testing.T)
}
}
func TestAgentProviderConformanceMarkerRetryRequiresExactMarkerReply(t *testing.T) {
prompt := nextConformanceRetryPrompt(true, true, false, 2)
for _, want := range []string{
"omitted the conformance marker",
"do not call more tools",
"do not summarize",
"Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal.",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("marker retry prompt %q missing %q", prompt, want)
}
}
}
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, attempt int) string {
if attempt >= 4 && sawTool && !sawBlockedDelegate {
return "Final conformance retry: emit exactly this tagged tool call so the harness can execute the guarded delegate refusal, then include agent-conformance-ok and the refusal in the final answer: " + conformanceDelegateTaggedCall
@@ -460,7 +297,7 @@ func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, att
case !sawBlockedDelegate:
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !hasMarker:
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools, do not summarize, and do not use synonyms. Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal."
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
default:
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
}
@@ -780,61 +617,6 @@ func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
}
}
func TestAgentRepairsPartialTextToolCallFallback(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
attempts++
if attempts == 1 {
return &ai.Response{Reply: `<tool_call name="conformance_echo">`}, nil
}
if !strings.Contains(req.Prompt, "did not finish valid tool-call markup") {
return nil, fmt.Errorf("repair prompt = %q, want partial tool-call repair guidance", req.Prompt)
}
return &ai.Response{
Reply: `<tool_call name="conformance_echo">{"value":"agent-conformance"}</tool_call>`,
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-partial-text-tool"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the partial text tool call fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want repair retry", attempts)
}
if !sawTool {
t.Fatal("repaired text tool call fallback did not execute the tool")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
}
func TestAgentExecutesTextToolCallFallbackAfterStructuredToolCall(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
-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.
+5 -40
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,21 +370,19 @@ 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
}
spanAttrs := appendRunInfoAttributes([]attribute.KeyValue{
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrToolName, call.Name),
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
}, info)
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 +392,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 +407,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
} else {
span.SetStatus(codes.Ok, "")
}
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, 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 +494,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 +528,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 +614,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 +661,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"
}
+3 -104
View File
@@ -103,10 +103,8 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
t.Fatalf("model span missing model event: %#v", s.Events())
}
}
if s.Name() == spanNameToolCall {
if !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
t.Fatalf("tool span missing tool event: %#v", s.Events())
}
if s.Name() == spanNameToolCall && !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
t.Fatalf("tool span missing tool event: %#v", s.Events())
}
}
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
@@ -144,45 +142,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
}
func TestAgentOpenTelemetryToolSpanIncludesWorkflowRunInfo(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("workflow-tool"), Provider("oteltest"), WithStore(st), TraceProvider(tp)).(*agentImpl)
handler := a.traceTool(func(context.Context, ai.ToolCall) ai.ToolResult {
return ai.ToolResult{Value: "ok"}
})
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{
RunID: "run-workflow-tool",
ParentID: "parent-run",
Agent: "workflow-tool",
Flow: "deploy",
Step: "notify",
Dispatch: "workflow",
Trigger: "manual",
})
res := handler(ctx, ai.ToolCall{ID: "call-1", Name: "notify", Input: map[string]any{"ok": true}})
if resultError(res) != "" {
t.Fatalf("tool returned error: %#v", res)
}
for _, span := range exp.GetSpans().Snapshots() {
if span.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(span.Attributes())
if attrs[AttrRunID] != "run-workflow-tool" || attrs[AttrParentRunID] != "parent-run" || attrs[AttrAgentName] != "workflow-tool" {
t.Fatalf("tool span missing run lineage: %#v", attrs)
}
if attrs[AttrFlowName] != "deploy" || attrs[AttrFlowStep] != "notify" || attrs[AttrDispatch] != "workflow" || attrs[AttrTrigger] != "manual" {
t.Fatalf("tool span missing workflow run info: %#v", attrs)
}
return
}
t.Fatalf("tool span not emitted; got %d spans", len(exp.GetSpans().Snapshots()))
}
func TestAgentOpenTelemetryToolRetryAttempts(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
@@ -421,63 +380,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 +622,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 {
-55
View File
@@ -240,55 +240,6 @@ func TestAskCancellationDuringToolCallFailsRun(t *testing.T) {
}
}
func TestSlowProviderTimeoutPreventsLateToolSideEffects(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
close(started)
<-release
defer close(done)
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}})
if !strings.Contains(res.Content, context.DeadlineExceeded.Error()) {
t.Errorf("late tool result = %q, want deadline exceeded", res.Content)
}
return &ai.Response{Reply: "late", ToolCalls: []ai.ToolCall{{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}, Result: res.Content}}}, nil
}
defer func() { fakeGen = nil }()
toolRuns := 0
a := newTestAgent(
Name("slow-provider-late-tool"),
ModelCallTimeout(10*time.Millisecond),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created", nil
}),
)
_, err := a.Ask(context.Background(), "provider times out before tool")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Ask error = %v, want deadline exceeded", err)
}
select {
case <-started:
default:
t.Fatal("provider was not called")
}
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("late provider call did not finish")
}
if toolRuns != 0 {
t.Fatalf("late tool executions = %d, want 0", toolRuns)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
@@ -327,12 +278,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)
}
-55
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")
@@ -275,29 +243,6 @@ func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestAgentStreamDoesNotRecordUserWhenProviderStreamingUnsupported(t *testing.T) {
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != "stream fallback" {
t.Fatalf("stream request messages = %+v, want pending user message", req.Messages)
}
return nil, ai.ErrStreamingUnsupported
}
defer func() { fakeStream = nil }()
mem := NewInMemory(8)
a := newTestAgent(Name("stream-fallback"), WithMemory(mem), WithTool("echo", "echo text", nil, func(context.Context, map[string]any) (string, error) {
return "ok", nil
}))
_, err := a.Stream(context.Background(), "stream fallback")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if got := mem.Messages(); len(got) != 0 {
t.Fatalf("memory after unsupported stream = %+v, want no recorded messages", got)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
+18 -218
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"regexp"
"strings"
@@ -13,17 +12,14 @@ import (
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>(.*?)</[^<>]*>`)
var taggedToolNameAttr = regexp.MustCompile(`(?i)(?:function|name|tool)\s*=\s*["\']?([^"\'\s>]+)`)
var openingTaggedToolCall = regexp.MustCompile(`(?i)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
type textToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments any `json:"arguments"`
Function *textToolCall `json:"function"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
@@ -94,7 +90,6 @@ func textToolCallKey(call ai.ToolCall) string {
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
text = html.UnescapeString(text)
allowed := textToolNames(tools)
if len(allowed) == 0 {
return nil
@@ -103,9 +98,6 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
if calls := decodeFunctionTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
@@ -114,37 +106,6 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
return nil
}
func partialTextToolCallName(text string, tools []ai.Tool) string {
text = html.UnescapeString(text)
allowed := textToolNames(tools)
if len(allowed) == 0 {
return ""
}
openMatches := openingTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
if len(openMatches) == 0 {
return ""
}
closedMatches := singleTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
for _, open := range openMatches {
closed := false
for _, match := range closedMatches {
if match[0] == open[0] {
closed = true
break
}
}
if closed {
continue
}
tag := text[open[2]:open[3]]
name := taggedToolName(tag)
if canonical := allowed[name]; canonical != "" {
return canonical
}
}
return ""
}
func textToolNames(tools []ai.Tool) map[string]string {
allowed := map[string]string{}
for _, tool := range tools {
@@ -215,7 +176,14 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
name, input := textToolCallNameAndInput(call)
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = call.Arguments
}
if name == "" || allowed[name] == "" || input == nil {
return nil
}
@@ -229,61 +197,6 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
}
}
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = textToolArguments(call.Arguments)
}
if call.Function != nil {
fnName, fnInput := textToolCallNameAndInput(*call.Function)
if name == "" {
name = fnName
}
if input == nil {
input = fnInput
}
}
return name, input
}
func textToolArguments(raw any) map[string]any {
switch args := raw.(type) {
case map[string]any:
return args
case string:
var input map[string]any
if err := json.Unmarshal([]byte(args), &input); err == nil {
return input
}
}
return nil
}
func 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) {
@@ -317,130 +230,17 @@ func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.Tool
}
func taggedToolName(tag string) string {
match := taggedToolNameAttr.FindStringSubmatch(tag)
if len(match) < 2 {
return ""
}
return strings.Trim(match[1], `"'`)
}
func decodeFunctionTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for alias, canonical := range allowed {
for _, body := range functionCallBodies(text, alias) {
var input map[string]any
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
continue
for _, marker := range []string{"function=", "name=", "tool="} {
if idx := strings.Index(tag, marker); idx >= 0 {
name := strings.TrimSpace(tag[idx+len(marker):])
name = strings.Trim(name, `"'`)
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
name = name[:end]
}
out = append(out, ai.ToolCall{
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
Name: canonical,
Input: input,
})
return strings.Trim(name, `"'`)
}
}
return out
}
func functionCallBodies(text, name string) []string {
if name == "" {
return nil
}
var bodies []string
for searchFrom := 0; searchFrom < len(text); {
idx := strings.Index(text[searchFrom:], name)
if idx < 0 {
break
}
start := searchFrom + idx
open := start + len(name)
if !isFunctionCallBoundary(text, start, open) {
searchFrom = start + len(name)
continue
}
bodyStart := open + 1
bodyEnd, ok := balancedJSONObjectEnd(text, bodyStart)
if !ok {
searchFrom = bodyStart
continue
}
bodies = append(bodies, strings.TrimSpace(text[bodyStart:bodyEnd]))
searchFrom = bodyEnd + 1
}
return bodies
}
func isFunctionCallBoundary(text string, start, open int) bool {
if open >= len(text) || text[open] != '(' {
return false
}
if start > 0 {
prev := text[start-1]
if prev == '_' || prev == '.' || prev == '-' || prev == '$' || ('0' <= prev && prev <= '9') || ('A' <= prev && prev <= 'Z') || ('a' <= prev && prev <= 'z') {
return false
}
}
for i := open + 1; i < len(text); i++ {
switch text[i] {
case ' ', '\n', '\r', '\t':
continue
case '{':
return true
default:
return false
}
}
return false
}
func balancedJSONObjectEnd(text string, start int) (int, bool) {
for start < len(text) {
switch text[start] {
case ' ', '\n', '\r', '\t':
start++
case '{':
depth := 0
inString := false
escaped := false
for i := start; i < len(text); i++ {
c := text[i]
if inString {
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
inString = false
}
continue
}
switch c {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
for j := i + 1; j < len(text); j++ {
switch text[j] {
case ' ', '\n', '\r', '\t':
continue
case ')':
return i + 1, true
default:
return 0, false
}
}
}
}
}
return 0, false
default:
return 0, false
}
}
return 0, false
return ""
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
-90
View File
@@ -56,93 +56,3 @@ func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
t.Fatalf("title = %v, want Design", got)
}
}
func TestParseTextToolCallsOpenAICompatibleFunctionArgumentsString(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `<tool_call>{"id":"call-2","type":"function","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsTaggedMarkupWithSpacedNameAttribute(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `<tool_call name = "delegate">{"task":"summarize the conformance marker","to":"blocked-reviewer"}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsHTMLEscapedTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `&lt;tool_call name=&quot;delegate&quot;&gt;{"task":"summarize the conformance marker","to":"blocked-reviewer"}&lt;/tool_call&gt;`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
}
func TestParseTextToolCallsFunctionCallSyntax(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `I will now call delegate({"task":"summarize the conformance marker","to":"blocked-reviewer"}) before answering.`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsFunctionCallSyntaxHandlesNestedJSON(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `delegate({
"task":"summarize the {escaped} marker",
"meta":{"note":"paren ) and brace } in string"},
"to":"blocked-reviewer"
})`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if got := calls[0].Input["task"]; got != "summarize the {escaped} marker" {
t.Fatalf("task = %v, want nested JSON-safe task", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
+1 -2
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
@@ -298,7 +297,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
+52 -460
View File
@@ -24,7 +24,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -95,8 +94,6 @@ func (p *Provider) String() string { return "atlascloud" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
tools := atlascloudTools(req.Tools)
compatTools, compatPrompt := atlascloudMinimaxCompatTools(p.opts.Model, req.Tools)
textToolPrompt := atlascloudMinimaxTextToolPrompt(p.opts.Model, req.Tools)
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
@@ -107,9 +104,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
if compatPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": compatPrompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
@@ -125,45 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
if err != nil {
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
apiReq["tools"] = compatTools
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
}
if atlascloudShouldRetryMinimaxTextTools(err, textToolPrompt) {
delete(apiReq, "tools")
apiReq["messages"] = append(messages, map[string]any{"role": "system", "content": textToolPrompt})
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-text-tools", apiReq)
}
if err != nil {
return nil, err
}
}
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
}
return nil, err
}
if len(resp.ToolCalls) == 0 {
@@ -171,105 +127,76 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
if p.opts.ToolHandler != nil {
var allToolCalls []ai.ToolCall
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
var toolResults []string
pendingToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for attempt := 0; len(pendingToolCalls) > 0 && attempt < 4; attempt++ {
for _, tc := range pendingToolCalls {
result := p.opts.ToolHandler(ctx, tc)
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
if content != "" {
toolResults = append(toolResults, content)
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
}
if len(tools) > 0 {
// Keep the tool schema available during the follow-up turn. Minimax
// models behind Atlas Cloud sometimes call one required tool, inspect
// that result, and then issue a second tool call (for example a guarded
// delegate conformance check) instead of completing immediately.
followUpReq["tools"] = tools
}
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
return nil, err
}
if len(followUpResp.ToolCalls) > 0 {
for i := range followUpResp.ToolCalls {
result := p.opts.ToolHandler(ctx, followUpResp.ToolCalls[i])
if result.Refused != "" {
tc.Error = result.Refused
followUpResp.ToolCalls[i].Error = result.Refused
}
if result.Content != "" {
tc.Result = result.Content
followUpResp.ToolCalls[i].Result = result.Content
toolResults = append(toolResults, result.Content)
}
allToolCalls = append(allToolCalls, tc)
resp.ToolCalls = allToolCalls
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
resp.ToolCalls = allToolCalls
}
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns,
// which covers AtlasCloud/minimax turns that emit a second
// required call (for example guarded delegate) as markup instead
// of native tool_calls.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = followUpResp.Reply
}
if len(tools) > 0 {
// Keep the tool schema available during follow-up turns. Minimax
// models behind Atlas Cloud sometimes complete a multi-tool task
// one call at a time (plan, then service tools, then delegate).
followUpReq["tools"] = tools
}
followUpResp, followUpRawMessage, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
delete(followUpReq, "tools")
followUpReq["messages"] = atlascloudFollowUpMessagesWithoutTools(p.opts.Model, followUpMessages)
followUpResp, followUpRawMessage, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
}
if err != nil {
return nil, err
}
}
if len(followUpResp.ToolCalls) == 0 {
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = atlascloudAnswerWithRequiredToolMarkers(followUpResp.Reply, toolResults, allToolCalls)
}
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
break
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "assistant",
"content": followUpRawMessage["content"],
"tool_calls": followUpRawMessage["tool_calls"],
})
pendingToolCalls = followUpResp.ToolCalls
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
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) {
@@ -381,33 +308,6 @@ func (s *atlasStream) Close() error {
return s.body.Close()
}
type atlascloudAPIError struct {
Status string
Code int
Retry time.Duration
Phase string
Summary string
Body string
}
func (e *atlascloudAPIError) Error() string {
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
}
func (e *atlascloudAPIError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *atlascloudAPIError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return e.Retry
}
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
@@ -431,12 +331,7 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
retryAfter := time.Duration(0)
var retryErr interface{ RetryAfter() time.Duration }
if errors.As(ai.NewHTTPError(httpResp, respBody), &retryErr) {
retryAfter = retryErr.RetryAfter()
}
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, Code: httpResp.StatusCode, Retry: retryAfter, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
}
var chatResp struct {
@@ -481,309 +376,6 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
return response, rawMessage, nil
}
func atlascloudFollowUpMessagesWithoutTools(model string, messages []map[string]any) []map[string]any {
if !atlascloudIsMinimaxModel(model) {
return messages
}
out := make([]map[string]any, 0, len(messages)+1)
for _, msg := range messages {
role, _ := msg["role"].(string)
switch role {
case "assistant":
converted := map[string]any{"role": "assistant"}
if content, _ := msg["content"].(string); content != "" {
converted["content"] = content
} else if calls, ok := msg["tool_calls"]; ok {
converted["content"] = "Tool call requested: " + atlascloudToolCallsText(calls)
} else {
converted["content"] = ""
}
out = append(out, converted)
case "tool":
toolID, _ := msg["tool_call_id"].(string)
content, _ := msg["content"].(string)
if toolID != "" {
content = "Tool result for " + toolID + ": " + content
} else {
content = "Tool result: " + content
}
out = append(out, map[string]any{"role": "user", "content": content})
default:
copyMsg := make(map[string]any, len(msg))
for k, v := range msg {
copyMsg[k] = v
}
out = append(out, copyMsg)
}
}
return out
}
func atlascloudToolCallsText(calls any) string {
b, err := json.Marshal(calls)
if err != nil {
return fmt.Sprint(calls)
}
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, ""
}
var native []ai.Tool
var builtins []string
for _, tool := range input {
switch tool.Name {
case "plan", "request_input", "delegate":
builtins = append(builtins, tool.Name)
default:
native = append(native, tool)
}
}
if len(builtins) == 0 || len(native) == len(input) {
return nil, ""
}
prompt := "AtlasCloud/minimax compatibility: use native tool_calls for the listed service tools. " +
"For built-in agent tools that are not listed natively (" + strings.Join(builtins, ", ") +
"), emit exactly <tool_call name=\"tool_name\">{...}</tool_call> so the agent runtime can execute them. Do not describe those built-in tool calls in prose instead of emitting the tag."
return atlascloudTools(native), prompt
}
func atlascloudMinimaxTextToolPrompt(model string, input []ai.Tool) string {
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
return ""
}
names := make([]string, 0, len(input))
for _, tool := range input {
if tool.Name != "" {
names = append(names, tool.Name)
}
}
if len(names) == 0 {
return ""
}
return "AtlasCloud/minimax text-tool compatibility: the native tools payload was rejected. " +
"Call exactly one needed tool from this list by emitting exactly <tool_call name=\"tool_name\">{...}</tool_call>: " +
strings.Join(names, ", ") + ". Do not answer in prose instead of emitting the tag."
}
func atlascloudShouldRetryMinimaxTextTools(err error, prompt string) bool {
if prompt == "" {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
}
func atlascloudIsMinimaxModel(model string) bool {
model = strings.ToLower(model)
return strings.Contains(model, "minimax")
}
func atlascloudShouldRetryMinimaxCompat(err error, compatTools []map[string]any) bool {
if len(compatTools) == 0 {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
}
func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
if _, ok := req["tools"]; !ok {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
}
func atlascloudTools(input []ai.Tool) []map[string]any {
tools := make([]map[string]any, 0, len(input))
for _, t := range input {
+2 -427
View File
@@ -355,8 +355,6 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-2","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"blocked by policy"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
@@ -403,78 +401,11 @@ 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])
}
}
func TestProvider_GenerateExecutesMultiStepFollowUpToolCalls(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-plan","function":{"name":"plan","arguments":"{\"steps\":[{\"task\":\"create tasks\"},{\"task\":\"notify owner\"}]}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-add","function":{"name":"task_TaskService_Add","arguments":"{\"title\":\"Design\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-delegate","function":{"name":"delegate","arguments":"{\"task\":\"notify owner@acme.com\",\"to\":\"comms\"}"}}]}}]}`))
case 4:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var calls []string
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
calls = append(calls, call.Name)
return ai.ToolResult{ID: call.ID, Content: `{"ok":true}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan, create tasks, and delegate notification",
Tools: []ai.Tool{
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
wantCalls := []string{"plan", "task_TaskService_Add", "delegate"}
if strings.Join(calls, ",") != strings.Join(wantCalls, ",") {
t.Fatalf("tool calls = %v, want %v", calls, wantCalls)
}
if len(resp.ToolCalls) != 3 {
t.Fatalf("ToolCalls = %+v, want all multi-step calls", resp.ToolCalls)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want final follow-up reply", resp.Answer)
}
if len(bodies) != 4 {
t.Fatalf("requests = %d, want initial plus three follow-ups", len(bodies))
}
for i := 1; i < 4; i++ {
if _, ok := bodies[i]["tools"].([]any); !ok {
t.Fatalf("follow-up request %d did not include tools: %#v", i+1, bodies[i])
}
}
}
func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -523,362 +454,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) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL), ai.WithModel("minimaxai/minimax-m3"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan and delegate",
Tools: []ai.Tool{
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
t.Fatalf("Reply = %q, want text delegate fallback", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus compat retry", len(bodies))
}
initialTools := bodies[0]["tools"].([]any)
if len(initialTools) != 4 {
t.Fatalf("initial tools = %d, want all tools", len(initialTools))
}
retryTools := bodies[1]["tools"].([]any)
if len(retryTools) != 1 {
t.Fatalf("retry tools = %d, want only service tools", len(retryTools))
}
fn := retryTools[0].(map[string]any)["function"].(map[string]any)
if fn["name"] != "task_TaskService_Add" {
t.Fatalf("retry tool name = %v, want service tool only", fn["name"])
}
msgs := bodies[1]["messages"].([]any)
compat := msgs[len(msgs)-1].(map[string]any)
if compat["role"] != "system" || !strings.Contains(compat["content"].(string), `<tool_call name="tool_name">`) {
t.Fatalf("compat instruction = %#v", compat)
}
}
func TestProvider_GenerateRetriesMinimaxServiceToolsAsTextTools(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 2:
if _, ok := body["tools"]; ok {
t.Fatalf("text-tool retry included native tools: %#v", body["tools"])
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"conformance_echo\">{\"value\":\"agent-conformance\"}</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: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="conformance_echo">`) {
t.Fatalf("Reply = %q, want text service-tool fallback", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus text-tool retry", len(bodies))
}
if _, ok := bodies[0]["tools"].([]any); !ok {
t.Fatalf("initial request did not include native tools: %#v", bodies[0])
}
msgs := bodies[1]["messages"].([]any)
compat := msgs[len(msgs)-1].(map[string]any)
content := compat["content"].(string)
for _, want := range []string{"native tools payload was rejected", `<tool_call name="tool_name">`, "conformance_echo"} {
if !strings.Contains(content, want) {
t.Fatalf("text-tool instruction %q missing %q", content, want)
}
}
}
func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 3:
if _, ok := body["tools"]; ok {
t.Fatalf("no-tools retry still included tools: %#v", body["tools"])
}
messages := body["messages"].([]any)
last := messages[len(messages)-1].(map[string]any)
if last["role"] == "tool" {
http.Error(w, `{"code":400,"msg":"trailing tool message rejected"}`, http.StatusBadRequest)
return
}
if last["role"] != "user" || !strings.Contains(last["content"].(string), "Tool result for call-1") {
t.Fatalf("no-tools retry last message = %#v, want user-visible tool result", last)
}
assistant := messages[len(messages)-2].(map[string]any)
if _, ok := assistant["tool_calls"]; ok {
t.Fatalf("no-tools retry assistant still included tool_calls: %#v", assistant)
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var toolCalls int
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
toolCalls++
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{Name: "conformance_echo", Description: "echo", Properties: map[string]any{"value": map[string]any{"type": "string"}}}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want done", resp.Answer)
}
if toolCalls != 1 {
t.Fatalf("tool handler calls = %d, want one (no duplicate side effect)", toolCalls)
}
if len(bodies) != 3 {
t.Fatalf("requests = %d, want chat, failed follow-up, no-tools follow-up", len(bodies))
}
if _, ok := bodies[1]["tools"]; !ok {
t.Fatalf("first follow-up did not include tools")
}
}
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
@@ -888,7 +463,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("deepseek-ai/DeepSeek-V3-0324"),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
@@ -902,7 +477,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
t.Fatal("Generate error = nil, want 400")
}
msg := err.Error()
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=deepseek-ai/DeepSeek-V3-0324", "tools=1", "tool_names=conformance_echo"} {
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
+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
+5 -137
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) {
@@ -277,7 +163,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var geminiResp struct {
@@ -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 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
ai.RegisterToolStream("groq")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
ai.RegisterToolStream("minimax")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("mistral")
ai.RegisterToolStream("mistral")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp 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 -2
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
@@ -286,7 +285,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
+8 -110
View File
@@ -4,9 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"strconv"
"strings"
"time"
)
@@ -22,77 +19,17 @@ type RetryAfterCoder interface {
RetryAfter() time.Duration
}
// HTTPError describes a failed provider HTTP response while preserving the
// status code and Retry-After signal for retry classifiers.
type HTTPError struct {
Status string
Code int
Body string
Header http.Header
}
func (e *HTTPError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("API error (%s): %s", e.Status, e.Body)
}
func (e *HTTPError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *HTTPError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return parseRetryAfter(e.Header.Get("Retry-After"), time.Now())
}
func NewHTTPError(resp *http.Response, body []byte) error {
if resp == nil {
return errors.New("API error: nil response")
}
return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: string(body), Header: resp.Header.Clone()}
}
func parseRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds <= 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
when, err := http.ParseTime(value)
if err != nil {
return 0
}
if delay := when.Sub(now); delay > 0 {
return delay
}
return 0
}
// ErrorKind classifies provider-boundary failures into stable buckets callers
// can inspect without parsing provider-specific error strings.
type ErrorKind string
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 +70,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.
@@ -163,7 +97,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
info.MaxAttempts = policy.MaxAttempts
callCtx = WithRunInfo(callCtx, info)
}
resp, err := generateAttempt(callCtx, m, req, opts...)
resp, err := m.Generate(callCtx, req, opts...)
cancel()
// Caller cancellation/deadline always wins and is not retried, even if
@@ -189,7 +123,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():
@@ -203,32 +137,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
}
func generateAttempt(ctx context.Context, m Model, req *Request, opts ...GenerateOption) (*Response, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
type result struct {
resp *Response
err error
}
done := make(chan result, 1)
go func() {
resp, err := m.Generate(ctx, req, opts...)
done <- result{resp: resp, err: err}
}()
select {
case res := <-done:
return res.resp, res.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
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 +155,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 +184,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 +196,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:
+5 -223
View File
@@ -3,8 +3,6 @@ package ai
import (
"context"
"errors"
"net/http"
"sync/atomic"
"testing"
"time"
)
@@ -69,54 +67,10 @@ 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
attempts := 0
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
attempts.Add(1)
attempts++
<-ctx.Done()
return nil, ctx.Err()
}}
@@ -136,8 +90,8 @@ func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
}
if got := attempts.Load(); got != 2 {
t.Fatalf("attempts = %d, want 2", got)
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
@@ -180,34 +134,6 @@ func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
}
}
func TestGenerateWithRetryReturnsWhenProviderIgnoresTimeout(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
model := retryModel{generate: func(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
close(started)
<-release
return &Response{Reply: "late"}, nil
}}
defer close(release)
start := time.Now()
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Timeout: 10 * time.Millisecond,
MaxAttempts: 1,
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("GenerateWithRetry error = %v, want deadline exceeded", err)
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("GenerateWithRetry took %s after deadline, want prompt return", elapsed)
}
select {
case <-started:
default:
t.Fatal("provider was not called")
}
}
type statusErr int
func (e statusErr) Error() string { return "provider status" }
@@ -230,13 +156,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) {
@@ -299,143 +221,3 @@ func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
}
}
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",
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"2"}},
}
err := NewHTTPError(resp, []byte("slow down"))
if got := ClassifyError(err); got != ErrorKindRateLimited {
t.Fatalf("ClassifyError() = %q, want %q", got, ErrorKindRateLimited)
}
var retryAfter RetryAfterCoder
if !errors.As(err, &retryAfter) {
t.Fatalf("NewHTTPError does not expose RetryAfterCoder")
}
if got := retryAfter.RetryAfter(); got != 2*time.Second {
t.Fatalf("RetryAfter() = %s, want 2s", got)
}
}
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 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("together")
ai.RegisterToolStream("together")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
-60
View File
@@ -1,60 +0,0 @@
package client
import (
"context"
raw "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/internal/network"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/transport"
"go-micro.dev/v6/transport/headers"
)
// localCall is the in-process fast-path for Call. When Local is enabled
// and the callee runs in this same process, a unary request whose body and
// response are raw frames (codec/bytes.Frame) is dispatched straight to the
// server's handlers via internal/network — no dial, no codec-over-socket,
// no transport pump. It returns handled=false to fall back to the network path
// for anything it does not cover (disabled, streaming, non-frame bodies, or a
// service not registered in-process), so behavior is unchanged unless the
// fast-path fully applies.
func (r *rpcClient) localCall(ctx context.Context, req Request, resp interface{}) (handled bool, err error) {
if !r.opts.Local || req.Stream() {
return false, nil
}
reqFrame, ok := req.Body().(*raw.Frame)
if !ok {
return false, nil
}
respFrame, ok := resp.(*raw.Frame)
if !ok {
return false, nil
}
dispatch, ok := network.Lookup(req.Service())
if !ok {
return false, nil
}
header := make(map[string]string)
if md, ok := metadata.FromContext(ctx); ok {
for k, v := range md {
if k == headers.Message { // pub/sub topic header, never forwarded
continue
}
header[k] = v
}
}
header[headers.Request] = req.Service()
header[headers.Endpoint] = req.Endpoint()
header["Content-Type"] = req.ContentType()
header["Accept"] = req.ContentType()
reply, err := dispatch(ctx, &transport.Message{Header: header, Body: reqFrame.Data})
if err != nil {
return true, err
}
if reply != nil {
respFrame.Data = reply.Body
}
return true, nil
}
-139
View File
@@ -1,139 +0,0 @@
package client_test
import (
"context"
"encoding/json"
"testing"
"time"
"go-micro.dev/v6/client"
raw "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/server"
)
type EchoReq struct {
Msg string `json:"msg"`
}
type EchoRsp struct {
Msg string `json:"msg"`
}
type EchoHandler struct{}
func (EchoHandler) Echo(_ context.Context, req *EchoReq, rsp *EchoRsp) error {
rsp.Msg = "echo:" + req.Msg
return nil
}
// startEchoServer starts a real server on the given registry and returns a stop
// func. The server is reachable over the network transport and (via Start)
// registered for the in-process fast-path.
func startEchoServer(t testing.TB, reg registry.Registry) func() {
t.Helper()
srv := server.NewServer(
server.Name("echo.local"),
server.Address("127.0.0.1:0"),
server.Registry(reg),
)
if err := srv.Handle(srv.NewHandler(&EchoHandler{})); err != nil {
t.Fatalf("handle: %v", err)
}
if err := srv.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Wait for registration so the client's selector can find a node.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if svcs, err := reg.GetService("echo.local"); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
return func() { _ = srv.Stop() }
}
func newEchoClient(reg registry.Registry, opts ...client.Option) client.Client {
base := []client.Option{
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
client.ContentType("application/json"),
}
return client.NewClient(append(base, opts...)...)
}
// callEcho makes an echo call with a raw-frame body (the shape agent/MCP/flow
// dispatch uses) and returns the decoded reply.
func callEcho(t testing.TB, cl client.Client, msg string) EchoRsp {
t.Helper()
body, _ := json.Marshal(EchoReq{Msg: msg})
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
var rsp raw.Frame
if err := cl.Call(context.Background(), req, &rsp); err != nil {
t.Fatalf("call: %v", err)
}
var out EchoRsp
if err := json.Unmarshal(rsp.Data, &out); err != nil {
t.Fatalf("decode reply %q: %v", rsp.Data, err)
}
return out
}
// TestLocalMatchesNetwork proves the in-process fast-path returns the
// exact same result as the network path for the same handler and request.
func TestLocalMatchesNetwork(t *testing.T) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(t, reg)
defer stop()
net := newEchoClient(reg) // network path
local := newEchoClient(reg, client.Local()) // in-process fast-path
netRsp := callEcho(t, net, "hi")
localRsp := callEcho(t, local, "hi")
if netRsp.Msg != "echo:hi" {
t.Fatalf("network reply = %q, want echo:hi", netRsp.Msg)
}
if localRsp != netRsp {
t.Fatalf("fast-path reply %+v != network reply %+v", localRsp, netRsp)
}
}
// TestLocalFallsBackWhenNotLocal confirms a service not registered
// in-process still works via the network path even with Local on.
func TestLocalFallsBackWhenNotLocal(t *testing.T) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(t, reg)
defer stop()
// Local is on, but the call still resolves — the fast-path only
// engages when it fully applies, otherwise the network path runs.
local := newEchoClient(reg, client.Local())
if got := callEcho(t, local, "x").Msg; got != "echo:x" {
t.Fatalf("reply = %q, want echo:x", got)
}
}
func benchmarkEcho(b *testing.B, opts ...client.Option) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(b, reg)
defer stop()
cl := newEchoClient(reg, opts...)
body, _ := json.Marshal(EchoReq{Msg: "hi"})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
var rsp raw.Frame
if err := cl.Call(context.Background(), req, &rsp); err != nil {
b.Fatalf("call: %v", err)
}
}
}
func BenchmarkNetworkCall(b *testing.B) { benchmarkEcho(b) }
func BenchmarkLocalCall(b *testing.B) { benchmarkEcho(b, client.Local()) }
-16
View File
@@ -68,11 +68,6 @@ type Options struct {
PoolSize int
PoolTTL time.Duration
PoolCloseTimeout time.Duration
// Local, when true, lets a unary Call to a service running in this
// same process skip the network transport and dispatch directly to that
// server's handlers (raw byte bodies only). Off by default.
Local bool
}
// CallOptions are options used to make calls to a server.
@@ -186,17 +181,6 @@ func ContentType(ct string) Option {
}
}
// Local enables the in-process fast-path: a unary Call to a service
// running in the same process dispatches straight to that server's handlers
// (skipping dial, codec-over-socket, and the transport pump) when both request
// and response bodies are raw frames (codec/bytes.Frame) — the shape agent,
// MCP, and flow tool calls use. Falls back to the network path otherwise.
func Local() Option {
return func(o *Options) {
o.Local = true
}
}
// PoolSize sets the connection pool size.
func PoolSize(d int) Option {
return func(o *Options) {
-6
View File
@@ -83,12 +83,6 @@ func (r *rpcClient) call(
resp interface{},
opts CallOptions,
) error {
// In-process fast-path: if the callee runs in this process and both bodies
// are raw frames, dispatch directly and skip the network entirely.
if handled, err := r.localCall(ctx, req, resp); handled {
return err
}
address := node.Address
logger := r.Options().Logger
+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
+18 -71
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"
@@ -75,9 +74,7 @@ a new service automatically and start using it.
Examples:
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
micro chat --provider openai --prompt "list all users"
micro chat assistant --prompt "create a task"`,
ArgsUsage: "[agent]",
micro chat --provider openai --prompt "list all users"`,
Flags: []cli.Flag{
&cli.StringFlag{Name: "provider", Usage: "AI provider (anthropic, openai, gemini, groq, mistral, together, atlascloud)", EnvVars: []string{"MICRO_AI_PROVIDER"}},
&cli.StringFlag{Name: "api_key", Usage: "API key for the provider", EnvVars: []string{"MICRO_AI_API_KEY"}},
@@ -190,36 +187,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 {
@@ -349,7 +316,6 @@ func run(c *cli.Context) error {
baseURL := c.String("base_url")
singlePrompt := c.String("prompt")
streamOutput := c.Bool("stream")
targetAgent := c.Args().First()
if provider == "" {
provider = ai.AutoDetectProvider(baseURL)
@@ -357,36 +323,15 @@ func run(c *cli.Context) error {
if apiKey == "" {
apiKey = fallbackAPIKey(provider)
}
if apiKey == "" {
return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider))
}
reg := registry.DefaultRegistry
cl := clt.DefaultClient
tools := ai.NewTools(reg, ai.ToolClient(cl))
s := &session{
provider: provider,
apiKey: apiKey,
tools: tools,
reg: reg,
cl: cl,
hist: ai.NewHistory(50),
stream: streamOutput,
}
hasAgents := s.discoverAgents()
if targetAgent != "" {
if _, ok := s.agents[targetAgent]; !ok {
return fmt.Errorf("agent %q is not registered; run `micro agent list` to see available agents", targetAgent)
}
s.agents = map[string]agentInfo{targetAgent: s.agents[targetAgent]}
hasAgents = true
}
if targetAgent != "" && singlePrompt != "" {
return s.ask(c.Context, singlePrompt)
}
if apiKey == "" {
return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider))
}
// Built-in agent capabilities (plan, delegate), reused from the
// agent package so the direct-service fallback matches a real agent.
builtinTools, builtinHandle := agent.Builtins(
@@ -398,8 +343,17 @@ func run(c *cli.Context) error {
agent.APIKey(apiKey),
)
s.builtinTools = builtinTools
s.builtinHandle = builtinHandle
s := &session{
provider: provider,
apiKey: apiKey,
tools: tools,
reg: reg,
cl: cl,
hist: ai.NewHistory(50),
builtinTools: builtinTools,
builtinHandle: builtinHandle,
stream: streamOutput,
}
s.refreshTools()
// Wrap the tool handler to intercept generate calls
@@ -433,6 +387,9 @@ func run(c *cli.Context) error {
defer s.cleanup()
// Discover registered agents
hasAgents := s.discoverAgents()
if singlePrompt != "" {
return s.ask(c.Context, singlePrompt)
}
@@ -583,11 +540,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 +578,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))
}
}
+1 -5
View File
@@ -48,8 +48,7 @@ Guide: https://go-micro.dev/docs/guides/zero-to-hero.html`
const examplesWayfinding = `First-agent examples (no provider key required)
Run these from a go-micro repository checkout in this order. For the complete
examples map, open examples/INDEX.md:
Run these from a go-micro repository checkout in this order:
1. Smallest service-backed agent
go run ./examples/first-agent
@@ -82,9 +81,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}}`
`
)
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/urfave/cli/v2"
microcmd "go-micro.dev/v6/cmd"
)
func TestExamplesWayfindingIndexStaysLinked(t *testing.T) {
root := filepath.Join("..", "..")
files := map[string]string{}
for _, name := range []string{"README.md", "examples/README.md", "examples/INDEX.md"} {
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
files[name] = string(b)
}
for _, check := range []struct {
file string
want []string
}{
{
file: "README.md",
want: []string{"examples/INDEX.md", "examples/first-agent/", "examples/support/", "zero-to-hero.md"},
},
{
file: "examples/README.md",
want: []string{"./INDEX.md", "./first-agent/", "./support/", "./mcp/hello/", "./mcp/workflow/"},
},
{
file: "examples/INDEX.md",
want: []string{"go run ./examples/first-agent", "go run ./examples/support", "mcp/hello", "mcp/workflow", "flow-durable", "micro examples"},
},
} {
for _, want := range check.want {
if !strings.Contains(files[check.file], want) {
t.Fatalf("%s missing %q", check.file, want)
}
}
}
}
func TestExamplesCommandPointsAtWayfindingIndex(t *testing.T) {
examples := commandByName(t, "examples")
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro examples failed: %v", err)
}
for _, want := range []string{
"examples/INDEX.md",
"go run ./examples/first-agent",
"go run ./examples/support",
"micro zero-to-hero",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
}
}
_ = microcmd.DefaultCmd // keep this test coupled to the registered command package.
}
+1 -162
View File
@@ -2,8 +2,6 @@ package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -38,15 +36,12 @@ 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")
}
chat := commandByName(t, "chat")
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") || !strings.Contains(chat.Description, `micro chat assistant --prompt`) {
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
}
@@ -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 * * * *"},
+7 -28
View File
@@ -1,11 +1,9 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next tag when the default branch has
# new commits since the latest one, and pushes it with a PAT (<< .TokenSecret >>)
# so any tag-triggered release workflow fires. The bump reflects what shipped,
# read from the CHANGELOG [Unreleased] section: new features (Added/Changed) cut
# a MINOR; fixes/docs only cut a PATCH; breaking changes are skipped so a MAJOR
# stays a human decision.
# Generated by `micro loop init`. Cuts the next PATCH tag
# (<< .TagPrefix >>MAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (<< .TokenSecret >>) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
@@ -68,30 +66,11 @@ jobs:
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
# Choose the bump from what actually shipped, read from the CHANGELOG
# [Unreleased] section (kept current by the coherence role):
# new features (### Added / ### Changed) -> MINOR
# fixes/docs only -> PATCH
# breaking (### Removed / "(breaking)") -> skip; a major is a human call
UNRELEASED=""
if [ -f CHANGELOG.md ]; then
UNRELEASED=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
fi
if printf '%s\n' "$UNRELEASED" | grep -qiE '^### Removed|^### Changed \(breaking\)|BREAKING'; then
echo "CHANGELOG [Unreleased] contains breaking changes — a major release is a human decision. Skipping."
exit 0
elif printf '%s\n' "$UNRELEASED" | grep -qE '^### (Added|Changed)'; then
NEXT="<< .TagPrefix >>${major}.$((minor + 1)).0"
KIND="minor (new features)"
else
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
KIND="patch (fixes/docs only)"
fi
echo "cutting: $NEXT$KIND ($COUNT commits since $LATEST)"
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
-36
View File
@@ -1,36 +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.
- A dependency-free `Reconcile(desired, observed)` core that decides the one
action needed to converge (create / update / noop) and the `Ready`/`Error`
status conditions — no controller-runtime, no client-go, fully unit-testable.
A future operator binary supplies the observed state and applies the action;
only that adapter needs the Kubernetes client.
- Unit tests that validate the structural CRD fragments, the Agent-to-Deployment
mapping, and the reconcile decision/conditions.
## 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")
}
}
-32
View File
@@ -1,32 +0,0 @@
package kubernetes
import (
"embed"
"fmt"
)
// crdFS holds the canonical CRD manifests. They live as real YAML under
// config/crd/ so they can be applied directly (`kubectl apply -f
// deploy/kubernetes/config/crd/`) and are embedded here so the Go API serves
// the exact same bytes — one source of truth, no drift.
//
//go:embed config/crd/agent.yaml config/crd/service.yaml config/crd/flow.yaml
var crdFS embed.FS
// CRDManifests contains the alpha CRDs for Go Micro lifecycle resources, loaded
// from the embedded config/crd/ YAML.
var CRDManifests = map[Kind]string{
KindAgent: mustCRD("agent"),
KindService: mustCRD("service"),
KindFlow: mustCRD("flow"),
}
// mustCRD reads an embedded CRD manifest. The files are embedded at compile
// time, so a read error means a build/packaging bug, not a runtime condition.
func mustCRD(name string) string {
b, err := crdFS.ReadFile("config/crd/" + name + ".yaml")
if err != nil {
panic(fmt.Sprintf("kubernetes: embedded CRD %q missing: %v", name, err))
}
return string(b)
}
-105
View File
@@ -1,105 +0,0 @@
package kubernetes
import (
"fmt"
"reflect"
)
// Reconcile is the pure decision core an operator's reconcile loop runs: given
// a desired resource and the currently observed cluster state, it computes the
// one action needed to converge (create / update / nothing) plus the status
// conditions to publish. It does not talk to a cluster — no controller-runtime,
// no client-go — so the whole convergence decision is unit-testable. An adapter
// binary supplies Observed from the live cluster and applies the returned
// Action; that adapter is the only piece that needs the Kubernetes client.
// ActionType is the change a reconcile wants applied.
type ActionType string
const (
// ActionCreate means the workload does not exist yet and should be created.
ActionCreate ActionType = "create"
// ActionUpdate means the workload exists but drifts from desired.
ActionUpdate ActionType = "update"
// ActionNoop means the workload already matches desired.
ActionNoop ActionType = "noop"
)
// Action is the change Reconcile decided on, carrying the desired Deployment.
type Action struct {
Type ActionType
Deployment Deployment
}
// Observed is the current cluster state Reconcile compares against. The adapter
// fills it from the live cluster; a nil Deployment means "not created yet".
type Observed struct {
// Deployment is the workload as it currently exists, or nil if absent.
Deployment *Deployment
// ReadyReplicas is how many pods are ready, from the live Deployment status.
ReadyReplicas int32
}
// Condition is a status condition to publish on the resource — the ready/error
// signal for the inner-loop and deploy story. It mirrors the Kubernetes
// condition shape without importing the API types.
type Condition struct {
Type string `json:"type"` // "Ready" | "Error"
Status string `json:"status"` // "True" | "False" | "Unknown"
Reason string `json:"reason"`
Message string `json:"message,omitempty"`
}
// Reconcile computes the action to bring observed toward desired, plus the
// status conditions. A spec that fails to map returns an Error condition and
// the error (no action).
func Reconcile(desired Resource, observed Observed) (Action, []Condition, error) {
want, err := MapDeployment(desired)
if err != nil {
return Action{}, []Condition{{
Type: "Error", Status: "True", Reason: "InvalidSpec", Message: err.Error(),
}}, err
}
var action Action
switch {
case observed.Deployment == nil:
action = Action{Type: ActionCreate, Deployment: want}
case deploymentDiffers(*observed.Deployment, want):
action = Action{Type: ActionUpdate, Deployment: want}
default:
action = Action{Type: ActionNoop, Deployment: want}
}
return action, conditions(want, observed), nil
}
// conditions derives the Ready condition from observed state against desired.
func conditions(want Deployment, observed Observed) []Condition {
switch {
case observed.Deployment == nil:
return []Condition{{
Type: "Ready", Status: "False", Reason: "Creating",
Message: "workload not yet created",
}}
case observed.ReadyReplicas < want.Replicas:
return []Condition{{
Type: "Ready", Status: "False", Reason: "Progressing",
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
}}
default:
return []Condition{{
Type: "Ready", Status: "True", Reason: "Available",
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
}}
}
}
// deploymentDiffers reports whether the observed deployment drifts from desired
// on the fields this operator manages (replicas, container, labels). Fields the
// cluster owns (status, cluster-assigned metadata) are intentionally ignored.
func deploymentDiffers(current, want Deployment) bool {
return current.Replicas != want.Replicas ||
!reflect.DeepEqual(current.Pod.Container, want.Pod.Container) ||
!reflect.DeepEqual(current.Labels, want.Labels)
}
-88
View File
@@ -1,88 +0,0 @@
package kubernetes
import "testing"
func agentResource() Resource {
return Resource{
Kind: KindAgent,
Name: "support",
Namespace: "agents",
Spec: WorkloadSpec{Image: "example/support:v1", Replicas: 2, Registry: "kubernetes"},
}
}
func TestReconcileCreatesWhenAbsent(t *testing.T) {
action, conds, err := Reconcile(agentResource(), Observed{Deployment: nil})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionCreate {
t.Fatalf("action = %q, want create", action.Type)
}
if action.Deployment.Name != "support" || action.Deployment.Replicas != 2 {
t.Fatalf("desired deployment = %+v", action.Deployment)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Creating" {
t.Fatalf("ready condition = %+v, want False/Creating", ready)
}
}
func TestReconcileNoopWhenMatchedAndReady(t *testing.T) {
want, _ := MapDeployment(agentResource())
action, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 2})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionNoop {
t.Fatalf("action = %q, want noop", action.Type)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "True" || ready.Reason != "Available" {
t.Fatalf("ready condition = %+v, want True/Available", ready)
}
}
func TestReconcileUpdatesOnDrift(t *testing.T) {
current, _ := MapDeployment(agentResource())
current.Pod.Container.Image = "example/support:v0" // stale image → drift
action, _, err := Reconcile(agentResource(), Observed{Deployment: &current, ReadyReplicas: 2})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionUpdate {
t.Fatalf("action = %q, want update", action.Type)
}
if action.Deployment.Pod.Container.Image != "example/support:v1" {
t.Fatalf("update should carry the desired image, got %q", action.Deployment.Pod.Container.Image)
}
}
func TestReconcileProgressingWhenUnderReplicated(t *testing.T) {
want, _ := MapDeployment(agentResource())
_, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 1})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Progressing" {
t.Fatalf("ready condition = %+v, want False/Progressing", ready)
}
}
func TestReconcileErrorOnInvalidSpec(t *testing.T) {
// Missing image → MapDeployment fails → Error condition, no action.
_, conds, err := Reconcile(Resource{Kind: KindService, Name: "api"}, Observed{})
if err == nil {
t.Fatal("Reconcile should error on an invalid spec")
}
if e := findCondition(conds, "Error"); e == nil || e.Status != "True" || e.Reason != "InvalidSpec" {
t.Fatalf("error condition = %+v, want True/InvalidSpec", e)
}
}
func findCondition(conds []Condition, typ string) *Condition {
for i := range conds {
if conds[i].Type == typ {
return &conds[i]
}
}
return nil
}
-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")
}
})
-49
View File
@@ -1,49 +0,0 @@
# Examples wayfinding
Use this index when you want the shortest path from a first runnable agent to the
next services, agents, workflows, and interop examples. Every command below is
provider-free unless the example README says otherwise.
## Pick by goal
| Goal | Start here | Run or verify | Then try |
|------|------------|---------------|----------|
| Run the smallest no-secret agent | [`first-agent`](./first-agent/) | `go run ./examples/first-agent` | [`agent-demo`](./agent-demo/) for a larger service-backed agent |
| Prove the maintained 0→hero path | [`support`](./support/) | `go run ./examples/support` and `go test ./examples/support` | [`zero-to-hero` guide](../internal/website/docs/guides/zero-to-hero.md) |
| See planning and delegation | [`agent-plan-delegate`](./agent-plan-delegate/) | `go run ./examples/agent-plan-delegate` | [`plan-delegate` guide](../internal/website/docs/guides/plan-delegate.md) |
| Expose services through MCP | [`mcp/hello`](./mcp/hello/) | follow [`mcp`](./mcp/) setup | [`mcp/crud`](./mcp/crud/) and [`mcp/workflow`](./mcp/workflow/) |
| Try 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/) |
## Recommended adoption path
1. **First service:** run [`hello-world`](./hello-world/) to learn service
registration, handlers, client calls, and health checks.
2. **First agent:** run [`first-agent`](./first-agent/) with
`go run ./examples/first-agent`; it uses a deterministic mock model and needs
no provider key.
3. **0→hero reference:** run [`support`](./support/) with
`go run ./examples/support`; it keeps typed services, an agent chat loop, an
event-driven flow, and an approval gate in one maintained example.
4. **Interop next:** use [`mcp/hello`](./mcp/hello/), [`mcp/crud`](./mcp/crud/),
and [`mcp/workflow`](./mcp/workflow/) when you are ready to expose tools to
external AI clients.
5. **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
needs checkpointed, resumable deterministic work.
## CLI wayfinding
The installed CLI prints the same path:
```bash
micro examples
micro agent demo
micro zero-to-hero
```
Keep this file, [`README.md`](../README.md), and the `micro examples` output in
sync so new developers can find `examples/first-agent` and `examples/support`
from one documented path.
+2 -2
View File
@@ -7,8 +7,8 @@ coordinate work with workflows.
## Quick Start
Each example can be run with `go run .` from its directory unless its README says
otherwise. If you are new to the repo, start with the [examples wayfinding index](./INDEX.md)
or follow the first-agent path below instead of reading the directories alphabetically.
otherwise. If you are new to the repo, follow the first-agent path below instead
of reading the directories alphabetically.
## Recommended first-agent path
-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, "")
}
+1 -4
View File
@@ -92,10 +92,7 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
}
}
if a, ok := isAwaitInput(err); ok {
// A suspend is normal control flow, not a step error.
span.SetStatus(codes.Ok, "waiting: "+a.Key)
} else if err != nil {
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())

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