Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c478f50073 | |||
| 81f81460aa | |||
| ba7db2f315 | |||
| ed3e0e5a06 | |||
| bd433239d7 | |||
| 7b782589d3 | |||
| 06a4375e47 | |||
| 1b371470a9 | |||
| 86ef6232bb | |||
| 10a5a5b235 | |||
| 28c411f0f7 | |||
| 99a956dec3 | |||
| 84cb4532f5 | |||
| 3d0ea0666e | |||
| cddf85c218 | |||
| c4eec47cbc | |||
| 87f011471b | |||
| 8ed0c21aa3 | |||
| 93ecf886a5 | |||
| 0120d6eb49 | |||
| decc7ebe1d | |||
| fc4921087f | |||
| 98cbafd11a | |||
| bba3b8ba98 | |||
| 460f1ef45a | |||
| 2cf95b27c8 | |||
| 700b72b0d6 | |||
| 4c6d8ec80b | |||
| 96fc06b9e4 | |||
| 167ca22107 | |||
| 6681a0971a | |||
| 1d795ef975 | |||
| 4faafdf3e9 | |||
| 56df17ce25 | |||
| e82d44e94a | |||
| 7a70fcf114 | |||
| 3d35b77c23 | |||
| ec698505ec | |||
| b940dd4233 | |||
| 39e92203dc | |||
| 3fc2364eea | |||
| 801f8f0f83 | |||
| a565dce4a0 | |||
| 4806f2fa17 | |||
| a0bc2287ff | |||
| b751497385 | |||
| ad500d58c8 | |||
| cae5549c73 | |||
| 850b202964 | |||
| 892fc847f0 | |||
| 7f78bbf814 | |||
| fc6e24daa3 | |||
| 7e0b6fd3fa | |||
| 1091e68bc1 | |||
| 7d2586a2f9 | |||
| 2bd02cc960 | |||
| 9dccdb4f69 | |||
| d1a34efadc | |||
| 8c7284ab80 | |||
| 5274f7c44f | |||
| c77f19ec80 | |||
| cd576e780c | |||
| 86d66b446f | |||
| 4150e8dc89 | |||
| 60612dc664 | |||
| ed43db5276 | |||
| 9d6d2d6c91 | |||
| 776fe1a36a | |||
| 84c1ee7471 | |||
| 1a9e94219a | |||
| c7df280d93 | |||
| 3b367975a3 | |||
| 0d4a101532 | |||
| ad8ff2abde | |||
| 5e6d51d261 | |||
| 687a33faee | |||
| 48e752bbce | |||
| b9e4bd171f | |||
| d551996a46 | |||
| 80d2d7306f | |||
| 31e146e61d | |||
| 0e3a749556 | |||
| c9f238955c | |||
| c264589a1f | |||
| 6836b49fa0 | |||
| da5b1a2599 | |||
| 3560208edc | |||
| 5f7f733713 | |||
| 29e49c92ea | |||
| 271af4d98e | |||
| 66acee0416 | |||
| e70111426b | |||
| 0314c8f55b | |||
| b3cc4a4ba4 | |||
| 765a842f2b | |||
| 7604cac060 | |||
| 234da38cd5 | |||
| 967e1cfd9b | |||
| 02aac71019 | |||
| 415e95c0f1 | |||
| 36c42d0cfa | |||
| 459dff8e7a | |||
| 6c74ae0f9c | |||
| 224e107f1c | |||
| e0b9f1323f | |||
| 35d3d99428 | |||
| 656c0ce9d6 | |||
| 7734f5605d | |||
| affd61e69b | |||
| 171da77817 | |||
| 83a6b2004a | |||
| ae5ead7914 | |||
| a1d17ec984 | |||
| a8b89c2142 | |||
| 76fea3f01c | |||
| a5f8ff518d | |||
| cf9c015cf8 | |||
| 7248270f28 | |||
| 243d7904e8 | |||
| 364fb33cc8 | |||
| 7697b2587c | |||
| fa36114d73 | |||
| 5c5186a230 | |||
| 58a76bc32f | |||
| 2c562c8827 | |||
| ff1c6173cd | |||
| 2c7b2dbe51 | |||
| c51d53f5ec | |||
| 6a25bbfc05 | |||
| ff1bf69252 | |||
| 92853d353e | |||
| 391dc1cf05 | |||
| ae69583f34 | |||
| e2729b2c32 | |||
| ef46bfc37f | |||
| 81d61f93d0 | |||
| 5dc2a32233 | |||
| d8562edcf6 | |||
| 5a85cba982 | |||
| 019420c123 | |||
| 5b32014de1 | |||
| 32e522a2a9 | |||
| 811d617b46 | |||
| 32d0c46676 | |||
| 34e7cf1f5f | |||
| 091fb1d4df | |||
| 1c56d77c86 | |||
| 6f220089d5 | |||
| 58249e4a2f | |||
| e51cbfba01 | |||
| e68d0e0018 | |||
| e96898d362 | |||
| c88a090d10 | |||
| f6951d2bb0 | |||
| 94286204ad | |||
| d54139c02d | |||
| 5ff3760cd4 | |||
| 69ab520360 | |||
| 0c4ea84ee3 | |||
| e6a6c72038 | |||
| 7c036459cf | |||
| 83cb4ff1a9 | |||
| 941d43bdf5 | |||
| bb3e40ade7 | |||
| dbce523437 | |||
| 98125cd770 | |||
| 3454a08079 | |||
| decfa7c63e | |||
| b6980e27a9 | |||
| d92d63943c | |||
| ec1d46526c | |||
| 40308cf779 | |||
| e198810390 | |||
| 3ec50d1a7d | |||
| 327f99cd19 | |||
| 498aac59f4 | |||
| 6833b3c73c | |||
| ec27ce2e25 | |||
| 45cf24162b | |||
| 2778472096 | |||
| 9dfb35d85a | |||
| ac230b57ee | |||
| 96eea598fc | |||
| 97391e9a92 | |||
| 6f2fefc1e1 | |||
| 224446b948 | |||
| eb16370f03 | |||
| 3262034698 | |||
| 2963ac3fa6 | |||
| eaff193569 | |||
| 36fd5b7bcd | |||
| 2e89b01295 | |||
| e95d565502 | |||
| a826e01dd4 | |||
| f8619d89b6 | |||
| 8ddb71143a | |||
| 3699c88e10 | |||
| a2145cc3a9 | |||
| d0dce12797 | |||
| 2cfa776a66 | |||
| 24a64aadb0 | |||
| 5d3d570c4f | |||
| 304d14331c | |||
| 5429ff0f08 | |||
| 8c9521cc63 | |||
| 621aa68f13 | |||
| 32a337509b | |||
| 4a6ab4016d | |||
| ce7cff7409 | |||
| 84e37e413e | |||
| 49dfdffbb7 | |||
| 9cc49f7718 | |||
| a18050d910 | |||
| 9db77ac265 | |||
| d4bfb4db5a | |||
| 75bf15a7d6 | |||
| 153f178db6 | |||
| 15af99f587 | |||
| f4f0bcc459 | |||
| 5ed66e550d | |||
| 7f4c7d6771 | |||
| 3f5547e781 | |||
| 12144d66a6 | |||
| f2096bf0cf | |||
| f758297265 | |||
| f16b23cf76 | |||
| 9d4133c666 | |||
| 6ecfcd5cdf | |||
| 3ceb23e9cb | |||
| af4d81cba3 | |||
| 021e3e1bd9 | |||
| 3f17f7b106 | |||
| 4ed4fca47d | |||
| c2b11a1d31 | |||
| f7c2ff0a74 | |||
| ddf26acc47 | |||
| 54a80bc930 | |||
| 76f309f253 | |||
| 9a3e6b7e5e | |||
| eb76367b0b | |||
| 58b8eb3e6b | |||
| f12788bd80 | |||
| 47997134af | |||
| f35274af00 | |||
| fefe3e5b4a | |||
| b01fd477b5 | |||
| dbca408982 | |||
| fa9806c5a6 | |||
| 95e7c387b2 | |||
| cc54ae988d |
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a vulnerability
|
||||
url: https://github.com/micro/go-micro/security/advisories/new
|
||||
about: Privately disclose security vulnerabilities to the maintainers.
|
||||
- name: 💖 Sponsor Go Micro
|
||||
url: https://github.com/sponsors/asim
|
||||
about: Fund ongoing development and see your name or logo on the project.
|
||||
|
||||
@@ -21,10 +21,8 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Make AtlasCloud conformance deterministically request the echo tool** ([#3924](https://github.com/micro/go-micro/issues/3924)) — #3926 shipped the previous plan/delegate notify wait fix, so the queue should drop the completed #3918 item. The freshest live harness signal now fails even earlier: AtlasCloud/minimax-m3 completes without invoking the required `conformance_echo` tool. This is the highest Now-phase item because the getting-started and 0→hero contract depends on providers actually using service tools, not just returning prose, and the CI gate needs an actionable deterministic failure path.
|
||||
2. **Make AtlasCloud conformance deterministically exercise guarded delegation** ([#3917](https://github.com/micro/go-micro/issues/3917)) — after the echo-tool path is stable, the same live provider must attempt the blocked delegate path so guardrails are proven under a real model, not only mocks. This keeps plan/delegate safety provider-portable without public API changes and protects the services → agents → workflows story from drifting at the delegation seam.
|
||||
3. **Broaden provider streaming and keep chat/A2A streaming end to end** ([#3903](https://github.com/micro/go-micro/issues/3903)) — once the live gate is stable, streaming is the highest developer-visible Next-phase seam. Real chat and long-running A2A tasks need token streaming to stay coherent from provider → `ai.Stream` → `micro chat` → A2A `message/stream`, with mock/default CI coverage plus key-gated live provider checks and safe fallback for non-streaming providers.
|
||||
4. **Trace agent runs as OpenTelemetry spans** ([#3908](https://github.com/micro/go-micro/issues/3908)) — the blog/README/roadmap story promises an operable harness, and the developer on-ramp now includes chat, inspect, and run-history checkpoints. The next observability gap is production-grade trace correlation for `RunInfo`: steps, tool calls, delegation, status, durations, and failures should be visible as spans while defaulting to no-op when tracing is not configured.
|
||||
1. **Make AtlasCloud universe A2A reachability probe deterministic** ([#4504](https://github.com/micro/go-micro/issues/4504)) — Highest-value remaining Now-phase hardening item after #4595 shipped in #4616: the universe checkout flow completes, but the live AtlasCloud A2A reachability probe can still time out after side effects succeed. Fix this next so the services → agents → workflows harness fails safely without turning a reachable agent into a false negative.
|
||||
2. **Add a no-secret first-agent chat transcript check** ([#4618](https://github.com/micro/go-micro/issues/4618)) — Developer adoption stays weighted alongside hardening: README, the website, and the v6.3.15 blog now point at the provider-free first-agent path, but the 0→1 agent experience still needs an expected chat/tool-call transcript that a new developer can compare against before adding provider keys. Make the smallest first-agent path more walkable and CI-verifiable.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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."
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM conformance)
|
||||
runs-on: ubuntu-latest
|
||||
# Only on the daily schedule or a manual run — never automatically on
|
||||
# Only on the hourly schedule or a manual run — never automatically on
|
||||
# every push/PR, so changes don't quietly burn API credits. Trigger it
|
||||
# by hand (Actions → Harness → Run workflow) when changing the agent,
|
||||
# flow, or AI internals and you want a real-model check.
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.24
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: golangci-lint
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: "Loop: Release"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
@@ -66,11 +68,30 @@ jobs:
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
|
||||
esac
|
||||
NEXT="v${major}.${minor}.$((patch + 1))"
|
||||
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
|
||||
|
||||
# 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)"
|
||||
|
||||
git config user.name "loop release bot"
|
||||
git config user.email "noreply@users.noreply.github.com"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT."
|
||||
|
||||
@@ -8,7 +8,7 @@ name: "Loop: Triage"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
|
||||
workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.24
|
||||
go-version: "1.25"
|
||||
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.24
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Get dependencies
|
||||
|
||||
+157
-3
@@ -5,9 +5,10 @@ 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`).
|
||||
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.
|
||||
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.
|
||||
|
||||
> Earlier `2026.0x` headings are historical calendar-style markers from before
|
||||
> v6 tagging; they are kept for continuity and not reused.
|
||||
@@ -16,6 +17,159 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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/`)
|
||||
|
||||
### 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/`)
|
||||
|
||||
---
|
||||
|
||||
## [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/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.17] - July 2026
|
||||
|
||||
### Added
|
||||
- **First-agent examples CLI wayfinding** — `micro examples` now prints the maintained provider-free first-agent examples in copy/paste order. (`cmd/micro/`)
|
||||
- **0→hero CLI entrypoint** — `micro zero-to-hero` now points developers at the maintained no-secret services → agents → workflows harness and runnable examples. (`cmd/micro/`)
|
||||
- **First-agent tutorial smoke harness** — the first-agent tutorial path now has smoke coverage to keep the no-secret on-ramp runnable. (`internal/harness/`)
|
||||
- **No-secret agent debugging smoke** — the no-secret agent debugging path now has smoke coverage for the first-agent troubleshooting flow. (`internal/harness/`)
|
||||
- **Durable checkpoint resume smoke coverage** — durable agent resume after checkpointing now has focused smoke coverage. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate notify replays** — duplicate and replayed plan-delegate notifications are now idempotent, so resumed runs do not duplicate completed notifications. (`agent/`, `internal/harness/`)
|
||||
- **Provider conformance scheduling** — provider conformance workflow dispatches now guard their scheduling path more reliably. (`.github/workflows/`)
|
||||
- **Plan/delegate notification completion** — delegated notifications now preserve plan completion state more reliably, including duplicate, paraphrased, and delegated-owner notification paths. (`agent/`, `internal/harness/`)
|
||||
- **AtlasCloud tool fallback** — AtlasCloud built-in tool schemas and follow-up tool fallback handling now recover conformance delegate retries more reliably. (`ai/atlascloud/`, `agent/`)
|
||||
- **Agent conformance retry completion** — conformance retry prompts and completion handling are more deterministic for delegated agent runs. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent quickstart numbering** — the first-agent on-ramp numbering is consistent across the README and website docs. (`README.md`, `internal/website/docs/`)
|
||||
- **First-agent inspect command** — docs now use the maintained `micro inspect agent <name>` form. (`README.md`, `internal/website/docs/`)
|
||||
- **`micro loop` quickstart wayfinding** — docs now surface the loop quickstart from the public docs index and README wayfinding. (`README.md`, `internal/website/docs/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.16] - July 2026
|
||||
|
||||
### Added
|
||||
- **No-secret agent demo CLI** — the CLI now surfaces `micro agent demo`, making the provider-free first-agent path discoverable from the installed binary. (`cmd/micro/`)
|
||||
- **First-agent recovery doctor** — first-agent recovery checks now help diagnose install, scaffold, and provider setup issues before the live agent run. (`cmd/micro/`, `internal/website/docs/guides/`)
|
||||
|
||||
### Changed
|
||||
- **Architecture lifecycle docs** — the architecture guide now leads with the services → agents → workflows lifecycle and the first-agent on-ramp. (`internal/website/docs/architecture.md`)
|
||||
- **First-agent on-ramp** — README and website docs now lead new users through install troubleshooting, no-secret demos, the smallest first-agent example, debugging, and the 0→hero reference path in the same order. (`README.md`, `internal/website/docs/`)
|
||||
|
||||
### Fixed
|
||||
- **Config close idempotency** — config close paths now tolerate repeated closes safely. (`config/`)
|
||||
- **OpenTelemetry child span events** — agent traces now preserve child span events more reliably. (`agent/`)
|
||||
|
||||
### Documentation
|
||||
- **Security reporting** — security docs now route vulnerability reports through GitHub Security Advisories. (`SECURITY.md`, `internal/website/docs/`)
|
||||
- **Install troubleshooting** — the first-agent on-ramp now includes clearer install and PATH recovery guidance. (`internal/website/docs/guides/install-troubleshooting.md`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.15] - July 2026
|
||||
|
||||
### Added
|
||||
- **Anthropic streaming** — the Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider, with capability docs and parser coverage. (`ai/anthropic/`, `internal/website/docs/guides/`)
|
||||
- **AP2 mandate foundation for A2A** — the A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. (`gateway/a2a/`)
|
||||
- **Smallest first-agent example** — a no-secret, mock-model first-agent example gives the on-ramp a minimal runnable starting point. (`examples/first-agent/`)
|
||||
|
||||
### Changed
|
||||
- **First-agent CLI next steps** — CLI output now points new users toward the maintained first-agent path after scaffold/run milestones. (`cmd/micro/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — plan-delegate runs now preserve completed steps, guard ordering, require notify-before-completion, and stabilize checkpoint continuation paths. (`agent/`, `internal/harness/`)
|
||||
- **Provider text tool calls** — AtlasCloud and weaker-model fallback paths now recover tagged, `Create`-suffixed, mixed text/tool-call, and follow-up tool calls more reliably. (`agent/`, `ai/atlascloud/`)
|
||||
- **First-agent broker isolation** — the first-agent harness now isolates broker state more reliably across runs. (`internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent example path** — docs and website wayfinding now surface the smallest example, no-secret transcript, and 0→hero path together. (`README.md`, `internal/website/docs/`)
|
||||
- **Agent operations guidance** — agent debugging docs now include operational failure guidance, inspect hints, and durable resume pointers. (`internal/website/docs/guides/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.14] - July 2026
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
|
||||
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
|
||||
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
|
||||
|
||||
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
.PHONY: test test-race test-coverage harness 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
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -19,6 +19,9 @@ 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 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"
|
||||
@@ -49,12 +52,37 @@ test-coverage:
|
||||
# This mirrors the default CI path so local dogfooding catches scaffold,
|
||||
# run/chat/inspect, and 0→hero regressions before a PR is opened.
|
||||
harness:
|
||||
$(MAKE) install-smoke
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
$(MAKE) cli-wayfinding
|
||||
$(MAKE) inner-loop
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
go run ./internal/harness/agent-flow
|
||||
$(MAKE) provider-conformance-mock
|
||||
|
||||
# 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:
|
||||
@@ -110,4 +138,3 @@ gorelease-dry-run:
|
||||
-w /$(NAME) \
|
||||
$(GORELEASER_DOCKER_IMAGE) \
|
||||
--clean --verbose --skip=publish,validate --snapshot
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ Running Go Micro in production, or building on it and want help? Paid **support,
|
||||
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
|
||||
- [Features](#features)
|
||||
- [CLI](#cli)
|
||||
- [Autonomous improvement loop](#autonomous-improvement-loop)
|
||||
- [Multi-Service Projects](#multi-service-projects)
|
||||
- [Data Model](#data-model)
|
||||
- [AI Providers](#ai-providers)
|
||||
@@ -50,6 +51,8 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
If install or `PATH` checks fail, use the [install troubleshooting guide](internal/website/docs/guides/install-troubleshooting.md) before scaffolding your first service.
|
||||
|
||||
### Fastest start — no API key
|
||||
|
||||
Scaffold a service, run it, call it:
|
||||
@@ -75,6 +78,12 @@ 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 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:
|
||||
|
||||
@@ -87,18 +96,44 @@ make harness
|
||||
After install and the first `micro new`/`micro run` smoke check, take the
|
||||
walkable agent path in this order:
|
||||
|
||||
1. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
|
||||
2. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
|
||||
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.
|
||||
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
|
||||
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
|
||||
3. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
9. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
service-backed agent and talk to it with `micro chat`.
|
||||
4. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
|
||||
`micro agent inspect`, run history, memory, and provider checks when the first
|
||||
conversation does something unexpected.
|
||||
5. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
|
||||
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
|
||||
services → agents → workflows loop with scaffold, run, chat, inspect, flow
|
||||
history, and deploy dry-run commands that match the maintained harness.
|
||||
|
||||
### Autonomous improvement loop
|
||||
|
||||
Want the same services → agents → workflows lifecycle applied to your
|
||||
repository? `micro loop` scaffolds the autonomous improvement loop used by Go
|
||||
Micro itself: a North Star, ranked issue queue, role prompts, GitHub Actions
|
||||
workflows, and verification for CI-gated PRs.
|
||||
|
||||
```bash
|
||||
micro loop init --roles all
|
||||
micro loop verify
|
||||
```
|
||||
|
||||
Before turning on the schedule, configure a dispatch token such as
|
||||
`CODEX_TRIGGER_TOKEN`, protect the default branch with required CI checks
|
||||
(`go build ./...`, `go test ./...`, and `golangci-lint run ./...` for this
|
||||
repository), and seed `.github/loop/PRIORITIES.md` with one scoped issue per
|
||||
increment. See the [`micro loop` quickstart](internal/website/docs/guides/micro-loop.md)
|
||||
for the setup checklist and operating model.
|
||||
|
||||
### Generate from a prompt — with an LLM key
|
||||
|
||||
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
|
||||
@@ -440,9 +475,12 @@ resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
|
||||
|
||||
## Examples
|
||||
|
||||
New to agents? Follow the [first-agent on-ramp](#first-agent-on-ramp), then use the [examples index](examples/README.md) for the full services → agents → workflows map.
|
||||
|
||||
- [hello-world](examples/hello-world/) — Basic RPC service
|
||||
- [multi-service](examples/multi-service/) — Multiple services in one binary
|
||||
- [mcp](examples/mcp/) — MCP integration with AI agents
|
||||
- [first-agent](examples/first-agent/) — Smallest provider-free service-backed agent
|
||||
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
|
||||
- [agent-durable](examples/agent-durable/) — Checkpoint and resume an agent run without replaying completed tool side effects
|
||||
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
|
||||
|
||||
+18
-4
@@ -14,8 +14,9 @@ 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), durable
|
||||
flows, the MCP and A2A gateways (both directions, including A2A streaming,
|
||||
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,
|
||||
push notifications, and multi-turn continuation), x402 paid tools, secure by
|
||||
default.
|
||||
|
||||
@@ -39,11 +40,24 @@ default.
|
||||
propagation, retry/backoff.
|
||||
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
|
||||
|
||||
## Shipped agent depth
|
||||
|
||||
- **Durable agent loop** — opt-in `Checkpoint` support lets agent `Ask` and
|
||||
streaming runs persist, list pending work, and resume without replaying completed
|
||||
tool calls. Human-input pauses resume through explicit input helpers.
|
||||
- **Agent observability** — agent `RunInfo` now feeds OpenTelemetry spans/events
|
||||
across runs, model turns, tool calls, retries, delegation lineage, and resume
|
||||
checkpoints.
|
||||
|
||||
## Next — agentic depth
|
||||
|
||||
- **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.
|
||||
- **Resume operations polish** — keep improving CLI/docs breadcrumbs for finding
|
||||
pending agent runs and deciding whether to call resume, resume-input, or stream
|
||||
resume in production.
|
||||
- **Observability hardening** — keep span attributes and run inspection coherent
|
||||
across agents, flows, and gateways as more providers and workflow paths are
|
||||
exercised.
|
||||
|
||||
## Later
|
||||
|
||||
|
||||
+3
-4
@@ -17,11 +17,11 @@ We actively support the following versions of go-micro:
|
||||
|
||||
### How to Report
|
||||
|
||||
Send security vulnerability reports to: **security@go-micro.dev**
|
||||
|
||||
Or use GitHub's private security advisory feature:
|
||||
Use GitHub's private security advisory feature:
|
||||
https://github.com/micro/go-micro/security/advisories/new
|
||||
|
||||
This keeps vulnerability reports private, ties follow-up to the affected repository, and avoids relying on project email routing.
|
||||
|
||||
### What to Include
|
||||
|
||||
Please include as much of the following information as possible:
|
||||
@@ -175,5 +175,4 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
|
||||
For security questions that are not vulnerabilities, please:
|
||||
- Open a discussion: https://github.com/micro/go-micro/discussions
|
||||
- Join Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
+88
-9
@@ -33,6 +33,7 @@ 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"
|
||||
@@ -99,6 +100,17 @@ 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.
|
||||
@@ -221,16 +233,18 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
messages := append([]ai.Message(nil), a.mem.Messages()...)
|
||||
messages = append(messages, ai.Message{Role: "user", Content: message})
|
||||
stream, err := a.model.Stream(ctx, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
|
||||
}
|
||||
|
||||
@@ -244,6 +258,31 @@ func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
return a.pending(ctx)
|
||||
}
|
||||
|
||||
// ResumePending resumes every checkpointed agent run that has not completed
|
||||
// yet, in the same oldest-first order returned by Pending.
|
||||
//
|
||||
// It is a convenience for service startup and recovery loops: after recreating
|
||||
// an agent with the same checkpoint store, call ResumePending to drain the
|
||||
// durable backlog without listing and resuming each run manually. If any run
|
||||
// fails again, ResumePending stops and returns that run id with the error so
|
||||
// callers can log, alert, or retry later without hiding the failing run.
|
||||
func ResumePending(ctx context.Context, ag Agent) (string, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("agent resume pending: unsupported agent implementation %T", ag)
|
||||
}
|
||||
runs, err := a.pending(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, run := range runs {
|
||||
if _, err := a.resume(ctx, run.ID); err != nil {
|
||||
return run.ID, err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -297,7 +336,11 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
}
|
||||
}
|
||||
|
||||
const maxPlanCompletionTurns = 3
|
||||
// Some providers satisfy a saved plan one outstanding item per turn,
|
||||
// especially when the final item delegates to another agent. Allow enough
|
||||
// continuations for the services → agents → workflows harness to complete
|
||||
// every planned side effect without weakening the final unfinished-plan guard.
|
||||
const maxPlanCompletionTurns = 6
|
||||
var resp *ai.Response
|
||||
for planCompletionTurn := 0; ; planCompletionTurn++ {
|
||||
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
|
||||
@@ -352,6 +395,15 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
resp.Reply = ""
|
||||
}
|
||||
}
|
||||
} else if calls, answer, ok := a.executeAdditionalTextToolCalls(ctx, resp.Reply, toolList, resp.ToolCalls); ok {
|
||||
resp.ToolCalls = append(resp.ToolCalls, calls...)
|
||||
if answer != "" {
|
||||
if resp.Answer == "" {
|
||||
resp.Answer = answer
|
||||
} else {
|
||||
resp.Answer += "\n" + answer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if a.opts.Checkpoint != nil {
|
||||
@@ -362,12 +414,21 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
if resp.Answer != "" {
|
||||
a.mem.Add("assistant", resp.Answer)
|
||||
}
|
||||
message = "Continue the run. These plan steps are still unfinished and must be completed before a final answer: " + strings.Join(unfinished, ", ")
|
||||
message = fmt.Sprintf("Continue the same run by calling the required tool(s) for the unfinished plan steps below. Do not repeat completed work, do not provide a final answer yet, and complete at least one unfinished step this turn if a matching tool is available. Unfinished plan steps: %s", strings.Join(unfinished, ", "))
|
||||
a.mem.Add("user", message)
|
||||
messages = a.mem.Messages()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -386,9 +447,13 @@ 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: resp.ToolCalls,
|
||||
ToolCalls: mergeCheckpointToolCalls(completedToolCalls, resp.ToolCalls),
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
@@ -460,7 +525,7 @@ func (a *agentImpl) Run() error {
|
||||
a.setup()
|
||||
}
|
||||
|
||||
a.server = server.NewServer(
|
||||
serverOpts := []server.Option{
|
||||
server.Name(a.opts.Name),
|
||||
server.Address(a.opts.Address),
|
||||
server.Registry(a.opts.Registry),
|
||||
@@ -468,7 +533,11 @@ func (a *agentImpl) Run() error {
|
||||
"type": "agent",
|
||||
"services": strings.Join(a.opts.Services, ","),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if a.opts.Broker != nil {
|
||||
serverOpts = append(serverOpts, server.Broker(a.opts.Broker))
|
||||
}
|
||||
a.server = server.NewServer(serverOpts...)
|
||||
|
||||
_ = pb.RegisterAgentHandler(a.server, a)
|
||||
|
||||
@@ -476,6 +545,11 @@ 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
|
||||
@@ -497,12 +571,17 @@ func (a *agentImpl) Run() error {
|
||||
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
|
||||
}
|
||||
|
||||
ch := make(chan struct{})
|
||||
<-ch
|
||||
<-stopCh
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ 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
|
||||
|
||||
+257
-12
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -26,6 +27,11 @@ const (
|
||||
toolHumanInput = "request_input"
|
||||
)
|
||||
|
||||
type delegateCall struct {
|
||||
done chan struct{}
|
||||
res ai.ToolResult
|
||||
}
|
||||
|
||||
// builtinTools returns the tool definitions exposed to the model in
|
||||
// addition to the agent's scoped service tools.
|
||||
func builtinTools() []ai.Tool {
|
||||
@@ -290,6 +296,11 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if call.Name == toolPlan {
|
||||
return a.handlePlan(call)
|
||||
}
|
||||
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, ", "))
|
||||
}
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused == "" && toolErrorMessage(res) == "" {
|
||||
a.completeNextPlanStep()
|
||||
@@ -364,12 +375,95 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
// 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 {
|
||||
data, err := json.Marshal(call.Input)
|
||||
input := preserveCompletedPlanSteps(a.loadPlan(), call.Input)
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return errResult(call.ID, "invalid plan: "+err.Error())
|
||||
}
|
||||
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
return ai.ToolResult{ID: call.ID, Value: input, Content: string(data)}
|
||||
}
|
||||
|
||||
func preserveCompletedPlanSteps(stored string, input map[string]any) map[string]any {
|
||||
if stored == "" {
|
||||
return input
|
||||
}
|
||||
var previous map[string]any
|
||||
if err := json.Unmarshal([]byte(stored), &previous); err != nil {
|
||||
return input
|
||||
}
|
||||
completed := completedPlanTasks(previous)
|
||||
if len(completed) == 0 {
|
||||
return input
|
||||
}
|
||||
steps, ok := input["steps"].([]any)
|
||||
if !ok {
|
||||
return input
|
||||
}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task, _ := step["task"].(string)
|
||||
if completed[planTaskCompletionKey(task)] && isUnfinishedPlanStatus(step["status"]) {
|
||||
step["status"] = "done"
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func completedPlanTasks(plan map[string]any) map[string]bool {
|
||||
steps, ok := plan["steps"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
completed := map[string]bool{}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status, _ := step["status"].(string)
|
||||
if status != "done" {
|
||||
continue
|
||||
}
|
||||
task, _ := step["task"].(string)
|
||||
if task = planTaskCompletionKey(task); task != "" {
|
||||
completed[task] = true
|
||||
}
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
func normalizePlanTask(task string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(task)), " ")
|
||||
}
|
||||
|
||||
func planTaskCompletionKey(task string) string {
|
||||
normalized := normalizePlanTask(task)
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if isLaunchReadinessDelegationPlanTask(normalized) {
|
||||
return "launch-readiness-notification"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isLaunchReadinessDelegationPlanTask(task string) bool {
|
||||
task = normalizePlanTask(task)
|
||||
if !strings.Contains(task, "notify") && !strings.Contains(task, "notification") {
|
||||
return false
|
||||
}
|
||||
hasLaunchReadiness := strings.Contains(task, "launch") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
|
||||
hasOwnerComms := strings.Contains(task, "owner") && strings.Contains(task, "comms")
|
||||
return hasLaunchReadiness || hasOwnerComms
|
||||
}
|
||||
|
||||
func isUnfinishedPlanStatus(status any) bool {
|
||||
s, _ := status.(string)
|
||||
return s == "" || s == "pending" || s == "in_progress"
|
||||
}
|
||||
|
||||
func (a *agentImpl) completeNextPlanStep() {
|
||||
@@ -402,6 +496,53 @@ func (a *agentImpl) completeNextPlanStep() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanStepsBeforeDelegation() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var unfinished []string
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task := planStepTask(step)
|
||||
if isDelegationPlanTask(task) {
|
||||
break
|
||||
}
|
||||
if !isUnfinishedPlanStatus(step["status"]) {
|
||||
continue
|
||||
}
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
unfinished = append(unfinished, task)
|
||||
}
|
||||
return unfinished
|
||||
}
|
||||
|
||||
func planStepTask(step map[string]any) string {
|
||||
if task, _ := step["task"].(string); task != "" {
|
||||
return task
|
||||
}
|
||||
desc, _ := step["description"].(string)
|
||||
return desc
|
||||
}
|
||||
|
||||
func isDelegationPlanTask(task string) bool {
|
||||
task = normalizePlanTask(task)
|
||||
return strings.Contains(task, "delegate") || strings.Contains(task, "notify") || strings.Contains(task, "notification")
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
@@ -425,7 +566,7 @@ func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
if status != "" && status != "pending" && status != "in_progress" {
|
||||
continue
|
||||
}
|
||||
task, _ := step["task"].(string)
|
||||
task := planStepTask(step)
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
@@ -449,13 +590,22 @@ func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
||||
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
||||
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
||||
// the subtask, and its reply returned.
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
|
||||
input := call.Input
|
||||
task, _ := input["task"].(string)
|
||||
if task == "" {
|
||||
return errResult(call.ID, "task is required")
|
||||
}
|
||||
to, _ := input["to"].(string)
|
||||
if cached, ok := a.cachedDelegateResult(call.ID, to, task); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
key := delegateResultKey(to, task)
|
||||
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
|
||||
return cached
|
||||
}
|
||||
defer func() { a.finishDelegateCall(key, res) }()
|
||||
|
||||
// An external agent on another framework, addressed by A2A URL.
|
||||
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
|
||||
@@ -463,9 +613,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
|
||||
}
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
}
|
||||
|
||||
// Delegate-first: an existing agent that owns the domain handles it.
|
||||
@@ -474,9 +622,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
|
||||
}
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
}
|
||||
|
||||
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
|
||||
@@ -509,9 +655,108 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
if err != nil {
|
||||
return errResult(call.ID, "sub-agent: "+err.Error())
|
||||
}
|
||||
out := map[string]any{"reply": resp.Reply}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
|
||||
}
|
||||
|
||||
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
|
||||
a.delegateMu.Lock()
|
||||
if a.delegateCalls == nil {
|
||||
a.delegateCalls = map[string]*delegateCall{}
|
||||
}
|
||||
if inFlight := a.delegateCalls[key]; inFlight != nil {
|
||||
a.delegateMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errResult(id, ctx.Err().Error()), true
|
||||
case <-inFlight.done:
|
||||
return withToolResultID(inFlight.res, id), true
|
||||
}
|
||||
}
|
||||
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
|
||||
a.delegateMu.Unlock()
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
|
||||
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
|
||||
a.delegateMu.Lock()
|
||||
inFlight := a.delegateCalls[key]
|
||||
if inFlight == nil {
|
||||
a.delegateMu.Unlock()
|
||||
return
|
||||
}
|
||||
inFlight.res = res
|
||||
delete(a.delegateCalls, key)
|
||||
close(inFlight.done)
|
||||
a.delegateMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
|
||||
recs, err := a.stateStore().Read(delegateResultKey(to, task))
|
||||
if err != nil || len(recs) == 0 {
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(recs[0].Value, &out); err != nil {
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
return ai.ToolResult{ID: id, Value: out, Content: string(b)}, true
|
||||
}
|
||||
|
||||
func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any) ai.ToolResult {
|
||||
b, _ := json.Marshal(out)
|
||||
_ = a.stateStore().Write(&store.Record{Key: delegateResultKey(to, task), Value: b})
|
||||
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
|
||||
}
|
||||
|
||||
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
|
||||
res.ID = id
|
||||
return res
|
||||
}
|
||||
|
||||
func delegateResultKey(to, task string) string {
|
||||
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
|
||||
sum := sha256.Sum256([]byte(fp))
|
||||
return fmt.Sprintf("delegate/%x", sum)
|
||||
}
|
||||
|
||||
func normalizeDelegateTarget(to string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(to))), " ")
|
||||
}
|
||||
|
||||
func normalizeDelegateTask(task string) string {
|
||||
task = strings.ToLower(strings.TrimSpace(task))
|
||||
task = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '@':
|
||||
return r
|
||||
default:
|
||||
return ' '
|
||||
}
|
||||
}, task)
|
||||
task = strings.Join(strings.Fields(task), " ")
|
||||
if strings.Contains(task, "owner") &&
|
||||
strings.Contains(task, "acme") &&
|
||||
isLaunchReadinessDelegateTask(task) {
|
||||
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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -52,6 +55,53 @@ func TestHandlePlanPersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanPreservesCompletedSteps(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "done"},
|
||||
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "done"},
|
||||
},
|
||||
}})
|
||||
|
||||
res := a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "done"},
|
||||
map[string]any{"task": " delegate readiness notification TO comms agent ", "status": "in_progress"},
|
||||
map[string]any{"task": "write summary", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
if res.Content == "" {
|
||||
t.Fatal("handlePlan returned empty content")
|
||||
}
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 1 || unfinished[0] != "write summary" {
|
||||
t.Fatalf("unfinished plan steps = %v, want only write summary", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanPreservesCompletedLaunchReadinessNotification(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "notify owner via comms", "status": "done"},
|
||||
},
|
||||
}})
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "Delegate launch readiness notification for owner@acme.com to comms agent", "status": "in_progress"},
|
||||
},
|
||||
}})
|
||||
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
|
||||
t.Fatalf("unfinished plan steps = %v, want launch readiness notification preserved as done", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanShowsInPrompt(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
|
||||
@@ -134,6 +184,74 @@ func TestBuiltinsAccessor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateResultCacheReusesLaunchReadinessParaphrases(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
firstTask := "Use the notify Send tool exactly once to tell owner@acme.com: The launch plan is ready."
|
||||
first := a.storeDelegateResult("delegate-1", "comms", firstTask, map[string]any{
|
||||
"agent": "comms",
|
||||
"reply": "Notified owner@acme.com.",
|
||||
})
|
||||
if first.Content == "" {
|
||||
t.Fatal("storeDelegateResult returned empty content")
|
||||
}
|
||||
|
||||
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.",
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateInFlightReplaysShareFirstResult(t *testing.T) {
|
||||
a := New(Name("planner"), WithStore(store.NewMemoryStore())).(*agentImpl)
|
||||
key := delegateResultKey("comms", "Notify owner@acme.com that the launch plan is ready")
|
||||
if _, joined := a.joinDelegateCall(context.Background(), "delegate-1", key); joined {
|
||||
t.Fatal("first delegate call unexpectedly joined an existing in-flight call")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
results := make(chan ai.ToolResult, 1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
res, joined := a.joinDelegateCall(context.Background(), "delegate-2", key)
|
||||
if !joined {
|
||||
t.Error("replayed delegate call did not join the in-flight call")
|
||||
return
|
||||
}
|
||||
results <- res
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-results:
|
||||
t.Fatalf("replayed delegate returned before first call finished: %+v", res)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
first := ai.ToolResult{ID: "delegate-1", Content: `{"reply":"Notified owner@acme.com."}`}
|
||||
a.finishDelegateCall(key, first)
|
||||
wg.Wait()
|
||||
replayed := <-results
|
||||
if replayed.ID != "delegate-2" {
|
||||
t.Fatalf("replayed result ID = %q, want delegate-2", replayed.ID)
|
||||
}
|
||||
if replayed.Content != first.Content {
|
||||
t.Fatalf("replayed content = %q, want %q", replayed.Content, first.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAgent(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
|
||||
@@ -165,3 +283,45 @@ func TestIsAgent(t *testing.T) {
|
||||
t.Error("isAgent(nonexistent) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrapBlocksDelegationUntilPriorPlanStepsFinish(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "Create Design task", "status": "pending"},
|
||||
map[string]any{"task": "Create Build task", "status": "pending"},
|
||||
map[string]any{"task": "Create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
|
||||
called := false
|
||||
handle := a.planWrap(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
called = true
|
||||
return ai.ToolResult{ID: call.ID, Content: "ok"}
|
||||
})
|
||||
|
||||
res := handle(context.Background(), ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if called {
|
||||
t.Fatal("delegate handler was called before prior task plan steps completed")
|
||||
}
|
||||
if res.Refused == "" {
|
||||
t.Fatalf("delegate result was not refused: %+v", res)
|
||||
}
|
||||
if got := res.Content; !containsStr(got, "Create Design task") || !containsStr(got, "Create Ship task") {
|
||||
t.Fatalf("delegate refusal content = %q, want prior unfinished task steps", got)
|
||||
}
|
||||
|
||||
for _, id := range []string{"add-design", "add-build", "add-ship"} {
|
||||
_ = handle(context.Background(), ai.ToolCall{ID: id, Name: "task.Add", Input: map[string]any{"title": id}})
|
||||
}
|
||||
called = false
|
||||
res = handle(context.Background(), ai.ToolCall{ID: "delegate-2", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if !called {
|
||||
t.Fatal("delegate handler was not called after prior task plan steps completed")
|
||||
}
|
||||
if res.Refused != "" {
|
||||
t.Fatalf("delegate result refused after prior task steps completed: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-13
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -50,9 +51,13 @@ func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
||||
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
||||
}
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
stage := run.State.Stage
|
||||
if stage == "" && len(run.Steps) > 0 {
|
||||
stage = run.Steps[0].Name
|
||||
}
|
||||
a.recordTimelineEvent(ctx, RunEvent{
|
||||
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
|
||||
Kind: "checkpoint", Name: run.State.Stage, Status: run.Status,
|
||||
Kind: "checkpoint", Name: stage, Status: run.Status,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
@@ -230,28 +235,32 @@ func agentOperationalError(err error) error {
|
||||
|
||||
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Checkpoint == nil || a.currentRun == nil {
|
||||
run := a.currentRun
|
||||
if a.opts.Checkpoint == nil || run == nil {
|
||||
return next(ctx, call)
|
||||
}
|
||||
name := toolCheckpointName(call)
|
||||
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
|
||||
if rec, ok := findStep(run.Steps, name); ok && rec.Status == "done" {
|
||||
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
|
||||
}
|
||||
|
||||
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
idx := upsertStep(&run.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
||||
_ = a.saveRun(ctx, *run)
|
||||
res := next(ctx, call)
|
||||
a.currentRun.Steps[idx].Attempts++
|
||||
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++
|
||||
if res.Refused != "" {
|
||||
a.currentRun.Steps[idx].Status = "failed"
|
||||
a.currentRun.Steps[idx].Error = res.Content
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
run.Steps[idx].Status = "failed"
|
||||
run.Steps[idx].Error = res.Content
|
||||
_ = a.saveRun(ctx, *run)
|
||||
return res
|
||||
}
|
||||
a.currentRun.Steps[idx].Status = "done"
|
||||
a.currentRun.Steps[idx].Result = res.Content
|
||||
a.currentRun.Steps[idx].Error = ""
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
run.Steps[idx].Status = "done"
|
||||
run.Steps[idx].Result = res.Content
|
||||
run.Steps[idx].Error = ""
|
||||
_ = a.saveRun(ctx, *run)
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -284,3 +293,56 @@ 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)
|
||||
}
|
||||
|
||||
+270
-3
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
@@ -20,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"}, nil
|
||||
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "external.lookup", Result: "cached"}}}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
@@ -48,6 +49,9 @@ 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)
|
||||
}
|
||||
@@ -100,6 +104,12 @@ 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)
|
||||
}
|
||||
@@ -144,6 +154,45 @@ 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")
|
||||
@@ -219,9 +268,76 @@ func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointContinuesRunThroughSeveralSingleStepTurns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "single-step-plan-agent")
|
||||
|
||||
completed := []string{}
|
||||
modelCalls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
modelCalls++
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
switch modelCalls {
|
||||
case 1:
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "pending"},
|
||||
map[string]any{"task": "create Build task", "status": "pending"},
|
||||
map[string]any{"task": "create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "delegate readiness notification", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
return &ai.Response{Reply: "planned"}, nil
|
||||
case 2, 3, 4, 5:
|
||||
want := []string{"create Design task", "create Build task", "create Ship task", "delegate readiness notification"}[modelCalls-2]
|
||||
if !strings.Contains(req.Prompt, want) {
|
||||
t.Fatalf("continuation prompt %d = %q, want %q", modelCalls, req.Prompt, want)
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: want, Name: "external.step", Input: map[string]any{"step": want}})
|
||||
if res.Content != "completed "+want {
|
||||
t.Fatalf("tool result = %q, want completed %s", res.Content, want)
|
||||
}
|
||||
if modelCalls == 5 {
|
||||
return &ai.Response{Reply: "all plan steps complete"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "one more step complete"}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected model call %d", modelCalls)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("single-step-plan-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.step", "complete one planned step", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
step, _ := input["step"].(string)
|
||||
completed = append(completed, step)
|
||||
return "completed " + step, nil
|
||||
}))
|
||||
resp, err := a.Ask(ctx, "work through the launch plan")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.Reply != "all plan steps complete" {
|
||||
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
|
||||
}
|
||||
if modelCalls != 5 {
|
||||
t.Fatalf("model calls = %d, want initial plus four continuations", modelCalls)
|
||||
}
|
||||
if len(completed) != 4 {
|
||||
t.Fatalf("completed steps = %v, want four tool-backed continuations", completed)
|
||||
}
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
|
||||
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
st := store.NewMemoryStore()
|
||||
cp := flow.StoreCheckpoint(st, "restart-resume-agent")
|
||||
toolRuns := 0
|
||||
modelCalls := 0
|
||||
failFirst := true
|
||||
@@ -242,7 +358,7 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
newAgent := func() *agentImpl {
|
||||
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
|
||||
return newTestAgent(Name("restart-resume-agent"), WithStore(st), WithCheckpoint(cp),
|
||||
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "provisioned", nil
|
||||
@@ -264,6 +380,19 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
|
||||
}
|
||||
summaries, err := ListRunSummaries(st, "restart-resume-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunSummaries before restart: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("run summaries before restart = %d, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "error" || summaries[0].Checkpoint != "failed" || summaries[0].Stage != agentAskStep {
|
||||
t.Fatalf("summary before restart = %#v, want failed ask checkpoint for %s", summaries[0], runs[0].ID)
|
||||
}
|
||||
if summaries[0].Events < 4 || summaries[0].LastError == "" {
|
||||
t.Fatalf("summary before restart lacks debug history/error: %#v", summaries[0])
|
||||
}
|
||||
|
||||
restarted := newAgent()
|
||||
resp, err := Resume(ctx, restarted, runs[0].ID)
|
||||
@@ -286,6 +415,99 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
|
||||
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
|
||||
}
|
||||
summaries, err = ListRunSummaries(st, "restart-resume-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunSummaries after restart: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("run summaries after restart = %d, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" || summaries[0].Stage != agentAskStep {
|
||||
t.Fatalf("summary after restart = %#v, want done ask checkpoint for %s", summaries[0], runs[0].ID)
|
||||
}
|
||||
if summaries[0].Events < 7 {
|
||||
t.Fatalf("summary after restart recorded %d events, want durable failure/resume/done history", summaries[0].Events)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "restart-resume-agent", runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunEvents after restart: %v", err)
|
||||
}
|
||||
seen := map[string]bool{"run": false, "tool": false, "checkpoint": false, "error": false, "resume": false, "done": false}
|
||||
for _, e := range events {
|
||||
if _, ok := seen[e.Kind]; ok {
|
||||
seen[e.Kind] = true
|
||||
}
|
||||
}
|
||||
for kind, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("events after restart missing %s: %#v", kind, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -354,6 +576,51 @@ func countMemoryContent(messages []ai.Message, needle string) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func TestResumePendingResumesOldestAgentRunsUntilFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "resume-pending-agent")
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
for _, run := range []flow.Run{
|
||||
{ID: "run-ok", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("ok")}, Started: base},
|
||||
{ID: "run-blocked", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("block")}, Started: base.Add(time.Minute)},
|
||||
{ID: "run-later", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("later")}, Started: base.Add(2 * time.Minute)},
|
||||
} {
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save(%s): %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
prompts = append(prompts, req.Prompt)
|
||||
if req.Prompt == "block" {
|
||||
return nil, errors.New("still blocked")
|
||||
}
|
||||
return &ai.Response{Reply: req.Prompt + " resumed"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("resume-pending-agent"), WithCheckpoint(cp))
|
||||
failedRun, err := ResumePending(ctx, a)
|
||||
if err == nil {
|
||||
t.Fatal("ResumePending succeeded, want blocked run error")
|
||||
}
|
||||
if failedRun != "run-blocked" {
|
||||
t.Fatalf("failed run = %q, want run-blocked", failedRun)
|
||||
}
|
||||
if got, want := strings.Join(prompts, ","), "ok,block"; got != want {
|
||||
t.Fatalf("prompts = %q, want %q", got, want)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, "run-ok")
|
||||
if err != nil || !ok || loaded.Status != "done" {
|
||||
t.Fatalf("run-ok loaded=%v err=%v status=%q, want done", ok, err, loaded.Status)
|
||||
}
|
||||
loaded, ok, err = cp.Load(ctx, "run-later")
|
||||
if err != nil || !ok || loaded.Status != "failed" {
|
||||
t.Fatalf("run-later loaded=%v err=%v status=%q, want still failed", ok, err, loaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
|
||||
|
||||
+578
-16
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,17 +22,20 @@ 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 := []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},
|
||||
}
|
||||
providers := agentConformanceProviders
|
||||
|
||||
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
|
||||
for _, provider := range providers {
|
||||
@@ -45,6 +49,151 @@ 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, ",") {
|
||||
@@ -118,7 +267,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
Name("conformance-" + provider.name),
|
||||
Provider(provider.name),
|
||||
APIKey(os.Getenv(provider.key)),
|
||||
Prompt("You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input {\"value\":\"agent-conformance\"}, then attempt to delegate a summary to blocked-reviewer. If the delegate is refused, explain the refusal and answer with the echo result."),
|
||||
Prompt(conformanceSystemPrompt(provider.name)),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(8)),
|
||||
@@ -155,7 +304,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
}
|
||||
|
||||
a := New(agentOpts...)
|
||||
resp, err := askWithConformanceToolRetry(context.Background(), a, "Run the provider conformance check.", &sawTool)
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
@@ -179,8 +328,8 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
|
||||
const maxAttempts = 2
|
||||
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
|
||||
const maxAttempts = 4
|
||||
prompt := initialPrompt
|
||||
var resp *Response
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
@@ -189,17 +338,141 @@ func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sawTool != nil && *sawTool {
|
||||
sawRequiredTool := sawTool == nil || *sawTool
|
||||
sawRequiredDelegate := sawBlockedDelegate == nil || *sawBlockedDelegate
|
||||
hasMarker := responseHasConformanceMarker(resp)
|
||||
if sawRequiredTool && sawRequiredDelegate && hasMarker {
|
||||
return resp, nil
|
||||
}
|
||||
if attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
prompt = "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: you must call conformance_echo exactly once with input {\"value\":\"agent-conformance\"} before any final answer, then include the tool result marker in the final answer."
|
||||
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker, attempt+1)
|
||||
}
|
||||
missing := missingConformanceRequirements(sawTool, sawBlockedDelegate, responseHasConformanceMarker(resp))
|
||||
if len(missing) > 0 {
|
||||
return resp, fmt.Errorf("provider conformance incomplete after %d attempts: missing %s", maxAttempts, strings.Join(missing, ", "))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
|
||||
return askWithConformanceRetry(ctx, a, initialPrompt, sawTool, nil)
|
||||
}
|
||||
|
||||
func missingConformanceRequirements(sawTool, sawBlockedDelegate *bool, hasMarker bool) []string {
|
||||
var missing []string
|
||||
if sawTool != nil && !*sawTool {
|
||||
missing = append(missing, "conformance_echo")
|
||||
}
|
||||
if sawBlockedDelegate != nil && !*sawBlockedDelegate {
|
||||
missing = append(missing, "guarded delegate")
|
||||
}
|
||||
if !hasMarker {
|
||||
missing = append(missing, "conformance marker")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
const (
|
||||
conformanceEchoInputJSON = `{"value":"agent-conformance"}`
|
||||
conformanceDelegateInputJSON = `{"task":"summarize the conformance marker","to":"blocked-reviewer"}`
|
||||
conformanceDelegateTaggedCall = `<tool_call name="delegate">` + conformanceDelegateInputJSON + `</tool_call>`
|
||||
)
|
||||
|
||||
func conformanceSystemPrompt(provider string) string {
|
||||
prompt := "You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then attempt to delegate a summary to blocked-reviewer with input " + conformanceDelegateInputJSON + ". You must complete both tool calls before any final answer; a final answer that only mentions the steps without calling both tools is invalid. If the delegate is refused, explain the refusal and answer with the echo result."
|
||||
if provider == "atlascloud" {
|
||||
prompt += " AtlasCloud/minimax conformance note: the delegate attempt is mandatory after conformance_echo. If native tool_calls are unavailable, emit the delegate as " + conformanceDelegateTaggedCall + " rather than answering in prose."
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(t *testing.T) {
|
||||
prompt := conformanceSystemPrompt("atlascloud")
|
||||
for _, want := range []string{
|
||||
"delegate attempt is mandatory",
|
||||
"You must complete both tool calls before any final answer",
|
||||
"<tool_call name=\"delegate\">",
|
||||
`{"task":"summarize the conformance marker","to":"blocked-reviewer"}`,
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("atlascloud conformance prompt %q missing %q", prompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(conformanceSystemPrompt("openai"), "AtlasCloud/minimax") {
|
||||
t.Fatal("non-AtlasCloud prompt should not include provider-specific fallback guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
|
||||
for name, prompt := range map[string]string{
|
||||
"missing tool": nextConformanceRetryPrompt(false, false, false, 2),
|
||||
"missing delegate": nextConformanceRetryPrompt(true, false, true, 2),
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"delegate exactly once",
|
||||
conformanceDelegateTaggedCall,
|
||||
"do not",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("%s retry prompt %q missing %q", name, prompt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFinalDelegateRetryUsesTaggedCall(t *testing.T) {
|
||||
prompt := nextConformanceRetryPrompt(true, false, true, 4)
|
||||
for _, want := range []string{
|
||||
"Final conformance retry",
|
||||
conformanceDelegateTaggedCall,
|
||||
"agent-conformance-ok",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("final delegate retry prompt %q missing %q", prompt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceMarkerRetryRequiresExactMarkerReply(t *testing.T) {
|
||||
prompt := nextConformanceRetryPrompt(true, true, false, 2)
|
||||
for _, want := range []string{
|
||||
"omitted the conformance marker",
|
||||
"do not call more tools",
|
||||
"do not summarize",
|
||||
"Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal.",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("marker retry prompt %q missing %q", prompt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, attempt int) string {
|
||||
if attempt >= 4 && sawTool && !sawBlockedDelegate {
|
||||
return "Final conformance retry: emit exactly this tagged tool call so the harness can execute the guarded delegate refusal, then include agent-conformance-ok and the refusal in the final answer: " + conformanceDelegateTaggedCall
|
||||
}
|
||||
switch {
|
||||
case !sawTool:
|
||||
return "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: first call conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not provide a final answer until both tool calls have been attempted. If native delegate tool_calls are unavailable after conformance_echo, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
|
||||
case !sawBlockedDelegate:
|
||||
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
|
||||
case !hasMarker:
|
||||
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools, do not summarize, and do not use synonyms. Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal."
|
||||
default:
|
||||
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
|
||||
}
|
||||
}
|
||||
|
||||
func responseHasConformanceMarker(resp *Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(resp.Reply, "agent-conformance-ok") || strings.Contains(resp.Reply, "agent-conformance")
|
||||
}
|
||||
|
||||
func validateConformanceRequest(req *ai.Request, opts ai.Options) error {
|
||||
if req.Prompt == "" {
|
||||
return errors.New("missing prompt")
|
||||
@@ -290,6 +563,176 @@ func TestAgentProviderConformanceRetriesMissingTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetriesMissingMarker(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempts == 1 {
|
||||
return &ai.Response{Reply: "called conformance_echo and handled guarded delegate refusal without the required marker"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "agent-conformance-ok after guarded delegate refusal"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
sawTool := true
|
||||
sawBlockedDelegate := true
|
||||
a := New(
|
||||
Name("conformance-retry-marker"),
|
||||
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) {
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want retry after missing marker", attempts)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetriesMissingDelegate(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
if attempts == 1 {
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo but skipped delegate",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
delegate := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-delegate-1",
|
||||
Name: "delegate",
|
||||
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo and handled guarded delegate refusal",
|
||||
Answer: echo.Content + " " + delegate.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-retry-delegate"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
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
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want retry after missing delegate", attempts)
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("retry did not attempt guarded delegate")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFailsWhenDelegateStillMissing(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: fmt.Sprintf("fake-call-%d", attempts),
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo with agent-conformance-ok but skipped delegate",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: fmt.Sprintf("fake-call-%d", attempts), Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-retry-delegate-exhausted"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
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
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err == nil || !strings.Contains(err.Error(), "guarded delegate") {
|
||||
t.Fatalf("Ask error = %v, want missing guarded delegate", err)
|
||||
}
|
||||
if attempts != 4 {
|
||||
t.Fatalf("attempts = %d, want retries through max attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
@@ -336,3 +779,122 @@ func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "structured-echo-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: echo.Content + "\n<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "structured-echo-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-mixed-text-tool"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
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
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(context.Background(), "Run the mixed structured/text tool fallback.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("structured conformance_echo did not execute")
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("tagged text delegate fallback did not execute")
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want structured echo and text delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("delegate ToolCall = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ func init() {
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
})
|
||||
ai.RegisterStream("fake")
|
||||
ai.RegisterToolStream("fake")
|
||||
}
|
||||
|
||||
// fakeClient embeds the default client (so NewRequest works) and
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -44,6 +45,7 @@ type Options struct {
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Broker broker.Broker
|
||||
Store store.Store
|
||||
HistoryLimit int
|
||||
|
||||
@@ -192,6 +194,13 @@ func WithClient(c client.Client) Option {
|
||||
return func(o *Options) { o.Client = c }
|
||||
}
|
||||
|
||||
// WithBroker sets the broker used by the agent service endpoint. Use an
|
||||
// in-memory broker in local harnesses/tests to avoid sharing the package-wide
|
||||
// default broker listener across concurrently running examples.
|
||||
func WithBroker(b broker.Broker) Option {
|
||||
return func(o *Options) { o.Broker = b }
|
||||
}
|
||||
|
||||
// WithStore sets the store for agent memory.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(o *Options) { o.Store = s }
|
||||
|
||||
+31
-8
@@ -36,6 +36,8 @@ const (
|
||||
AttrTotalTokens = "agent.tokens.total"
|
||||
AttrAttempt = "agent.model.attempt"
|
||||
AttrMaxAttempts = "agent.model.max_attempts"
|
||||
AttrToolAttempt = "agent.tool.attempt"
|
||||
AttrToolMaxAttempts = "agent.tool.max_attempts"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
@@ -215,13 +217,13 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
e.ErrorKind = string(ai.ClassifyError(err))
|
||||
}
|
||||
m.a.recordSpanEvent(span, e)
|
||||
span.End()
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -364,20 +366,33 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
resErr := resultError(res)
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
toolAttempts := res.Attempts
|
||||
if toolAttempts <= 0 {
|
||||
toolAttempts = 1
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
return res
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
|
||||
spanAttrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
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()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
toolAttempts := res.Attempts
|
||||
if toolAttempts <= 0 {
|
||||
toolAttempts = 1
|
||||
}
|
||||
attrs = append(attrs, attribute.Int(AttrToolAttempt, toolAttempts))
|
||||
if a.opts.ToolMaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, a.opts.ToolMaxAttempts))
|
||||
}
|
||||
if res.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
|
||||
}
|
||||
@@ -393,8 +408,8 @@ 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)})
|
||||
span.End()
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -461,10 +476,18 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
attrs = append(attrs, attribute.String(AttrModel, e.Model))
|
||||
}
|
||||
if e.Attempt > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolAttempt, e.Attempt))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
}
|
||||
}
|
||||
if e.MaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, e.MaxAttempts))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
}
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
|
||||
@@ -624,7 +647,7 @@ func runStatus(events []RunEvent) string {
|
||||
if e.Error != "" || e.Kind == "error" {
|
||||
status = runErrorStatus(e.ErrorKind)
|
||||
}
|
||||
if e.Kind == "done" && status == "running" {
|
||||
if e.Kind == "done" {
|
||||
status = "done"
|
||||
}
|
||||
}
|
||||
|
||||
+130
-2
@@ -95,8 +95,18 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
|
||||
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
|
||||
}
|
||||
if s.Name() == spanNameModelCall && (attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1") {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
if s.Name() == spanNameModelCall {
|
||||
if attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1" {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.model", runID, "runner") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
@@ -134,6 +144,121 @@ 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))
|
||||
st := store.NewMemoryStore()
|
||||
calls := 0
|
||||
a := New(
|
||||
Name("tool-retry-otel"),
|
||||
Provider("oteltest"),
|
||||
WithStore(st),
|
||||
TraceProvider(tp),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "", errors.New("rate limit exceeded")
|
||||
}
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("tool calls = %d, want retry success after 2 attempts", calls)
|
||||
}
|
||||
|
||||
var sawToolSpan bool
|
||||
for _, span := range exp.GetSpans().Snapshots() {
|
||||
if span.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(span.Attributes())
|
||||
if attrs[AttrToolName] != "probe" {
|
||||
continue
|
||||
}
|
||||
if attrs[AttrToolAttempt] != "2" || attrs[AttrToolMaxAttempts] != "3" {
|
||||
t.Fatalf("tool retry span attempts = %#v", attrs)
|
||||
}
|
||||
if !spanEventHasAttr(span.Events(), "agent.tool", AttrToolAttempt, "2") || !spanEventHasAttr(span.Events(), "agent.tool", AttrToolMaxAttempts, "3") {
|
||||
t.Fatalf("tool retry event missing attempt attributes: %#v", span.Events())
|
||||
}
|
||||
sawToolSpan = true
|
||||
}
|
||||
if !sawToolSpan {
|
||||
t.Fatal("tool retry span not emitted")
|
||||
}
|
||||
|
||||
summaries, err := ListRunSummaries(st, "tool-retry-otel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "tool-retry-otel", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Kind == "tool" && event.Name == "probe" && event.Attempt == 2 && event.MaxAttempts == 3 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("persisted tool event missing retry attempts: %#v", events)
|
||||
}
|
||||
|
||||
func spanEventHasAttr(events []trace.Event, name, key, value string) bool {
|
||||
for _, event := range events {
|
||||
if event.Name != name {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(event.Attributes)
|
||||
if attrs[key] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
|
||||
secret := "deploy production with token sk-secret"
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
@@ -655,6 +780,9 @@ func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
|
||||
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
|
||||
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.stream", "stream-run-1", "stream-runner") {
|
||||
t.Fatalf("stream span missing stream event: %#v", s.Events())
|
||||
}
|
||||
sawStream = true
|
||||
}
|
||||
if !sawStream {
|
||||
|
||||
@@ -83,6 +83,62 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRetryDoesNotDuplicateCheckpointedToolSideEffects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "retry-tool-dedupe-agent")
|
||||
attempts := 0
|
||||
toolRuns := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}})
|
||||
if res.Content != "created Retry safe" {
|
||||
t.Fatalf("tool result = %q, want cached create result", res.Content)
|
||||
}
|
||||
if attempts == 1 {
|
||||
return nil, testStatusError{code: 503}
|
||||
}
|
||||
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}, Result: res.Content}}}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(
|
||||
Name("retry-tool-dedupe-agent"),
|
||||
WithCheckpoint(cp),
|
||||
ModelRetry(2, time.Millisecond),
|
||||
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "created Retry safe", nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(ctx, "create once despite a transient provider retry")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.Reply != "done" {
|
||||
t.Fatalf("reply = %q, want done", resp.Reply)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("model attempts = %d, want retry after transient provider failure", attempts)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions = %d, want checkpointed side effect reused across retry", toolRuns)
|
||||
}
|
||||
runs, err := cp.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
|
||||
}
|
||||
if _, ok := findStep(runs[0].Steps, `tool:external.create:{"title":"Retry safe"}`); !ok {
|
||||
t.Fatalf("checkpoint steps = %#v, want completed external.create step", runs[0].Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, testStatusError{code: 429}
|
||||
@@ -184,6 +240,55 @@ 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
|
||||
|
||||
+10
-4
@@ -71,14 +71,15 @@ func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream,
|
||||
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
|
||||
// then emits chunks of the final answer followed by a Done event.
|
||||
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done}
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.askWithStreamEvents(ctx, message, events)
|
||||
resp, err := a.askWithStreamEvents(streamCtx, message, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -94,14 +95,15 @@ func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream,
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done}
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
|
||||
resp, err := a.resumeWithStreamEvents(streamCtx, runID, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -260,6 +262,7 @@ func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream,
|
||||
type agentStream struct {
|
||||
events <-chan *StreamEvent
|
||||
done <-chan struct{}
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
@@ -278,6 +281,9 @@ func (s *agentStream) Recv() (*StreamEvent, error) {
|
||||
}
|
||||
|
||||
func (s *agentStream) Close() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
<-s.done
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
@@ -75,6 +76,39 @@ func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAskCloseCancelsInFlightModelCall(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("stream-cancel"))
|
||||
stream, err := a.StreamAsk(context.Background(), "cancel me")
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAsk: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("model call did not start")
|
||||
}
|
||||
|
||||
closed := make(chan error, 1)
|
||||
go func() { closed <- stream.Close() }()
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not cancel the in-flight stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
|
||||
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
|
||||
if err == nil {
|
||||
@@ -209,6 +243,29 @@ func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStreamDoesNotRecordUserWhenProviderStreamingUnsupported(t *testing.T) {
|
||||
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != "stream fallback" {
|
||||
t.Fatalf("stream request messages = %+v, want pending user message", req.Messages)
|
||||
}
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
defer func() { fakeStream = nil }()
|
||||
|
||||
mem := NewInMemory(8)
|
||||
a := newTestAgent(Name("stream-fallback"), WithMemory(mem), WithTool("echo", "echo text", nil, func(context.Context, map[string]any) (string, error) {
|
||||
return "ok", nil
|
||||
}))
|
||||
|
||||
_, err := a.Stream(context.Background(), "stream fallback")
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
if got := mem.Messages(); len(got) != 0 {
|
||||
t.Fatalf("memory after unsupported stream = %+v, want no recorded messages", got)
|
||||
}
|
||||
}
|
||||
|
||||
type unsupportedAgent struct{}
|
||||
|
||||
func (unsupportedAgent) Name() string { return "unsupported" }
|
||||
|
||||
+318
-20
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -11,13 +12,18 @@ 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*=[^<>]*)>`)
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input map[string]any `json:"input"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
Arguments any `json:"arguments"`
|
||||
Function *textToolCall `json:"function"`
|
||||
}
|
||||
|
||||
// executeTextToolCalls is a compatibility fallback for providers that return a
|
||||
@@ -45,18 +51,61 @@ func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tool
|
||||
return calls, strings.Join(results, "\n"), true
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
allowed := map[string]bool{}
|
||||
for _, tool := range tools {
|
||||
allowed[tool.Name] = true
|
||||
if tool.OriginalName != "" {
|
||||
allowed[tool.OriginalName] = true
|
||||
}
|
||||
// executeAdditionalTextToolCalls runs text-encoded tool calls that accompany a
|
||||
// structured tool_calls response. Some OpenAI-compatible providers can mix the
|
||||
// two forms in a single assistant turn: for example, emitting a native
|
||||
// conformance_echo call while rendering a follow-up guarded delegate call as
|
||||
// <tool_call name="delegate">...</tool_call> text. Keep this fallback additive
|
||||
// and de-duplicate calls already represented in the structured tool_calls list.
|
||||
func (a *agentImpl) executeAdditionalTextToolCalls(ctx context.Context, reply string, tools []ai.Tool, existing []ai.ToolCall) ([]ai.ToolCall, string, bool) {
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, call := range existing {
|
||||
seen[textToolCallKey(call)] = true
|
||||
}
|
||||
|
||||
handler := a.toolHandler()
|
||||
out := make([]ai.ToolCall, 0, len(calls))
|
||||
results := make([]string, 0, len(calls))
|
||||
for i := range calls {
|
||||
if seen[textToolCallKey(calls[i])] {
|
||||
continue
|
||||
}
|
||||
result := handler(ctx, calls[i])
|
||||
calls[i].Result = result.Content
|
||||
if result.Refused != "" {
|
||||
calls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
results = append(results, result.Content)
|
||||
}
|
||||
out = append(out, calls[i])
|
||||
}
|
||||
return out, strings.Join(results, "\n"), len(out) > 0
|
||||
}
|
||||
|
||||
func textToolCallKey(call ai.ToolCall) string {
|
||||
b, _ := json.Marshal(call.Input)
|
||||
return call.Name + "\x00" + string(b)
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
text = html.UnescapeString(text)
|
||||
allowed := textToolNames(tools)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
@@ -65,6 +114,64 @@ 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 {
|
||||
addTextToolName(allowed, tool.Name, tool.Name)
|
||||
if tool.OriginalName != "" {
|
||||
addTextToolName(allowed, tool.OriginalName, tool.Name)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func addTextToolName(allowed map[string]string, name, canonical string) {
|
||||
if name == "" || canonical == "" {
|
||||
return
|
||||
}
|
||||
allowed[name] = canonical
|
||||
// Some OpenAI-compatible models describe an idempotent Add endpoint as a
|
||||
// creation action and emit the otherwise-correct service tool with a Create
|
||||
// suffix in text-only tool-call markup. Keep the fallback bounded by the
|
||||
// offered service tool prefix so ordinary unknown tools remain ignored.
|
||||
for _, suffix := range []string{"_Add", ".Add"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
allowed[strings.TrimSuffix(name, suffix)+strings.Replace(suffix, "Add", "Create", 1)] = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonCandidates(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
var out []string
|
||||
@@ -76,13 +183,18 @@ func jsonCandidates(text string) []string {
|
||||
out = append(out, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
for _, match := range taggedToolCallBlock.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) > 1 {
|
||||
out = append(out, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
|
||||
out = append(out, strings.TrimSpace(text[start:end+1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCall {
|
||||
func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolCall {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
|
||||
return nil
|
||||
@@ -90,7 +202,7 @@ func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCal
|
||||
return collectTextToolCalls(root, allowed)
|
||||
}
|
||||
|
||||
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
|
||||
func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
var out []ai.ToolCall
|
||||
@@ -103,27 +215,213 @@ func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
|
||||
return collectTextToolCalls(nested, allowed)
|
||||
}
|
||||
call := mapToTextToolCall(x)
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = call.Arguments
|
||||
}
|
||||
if name == "" || !allowed[name] || input == nil {
|
||||
name, input := textToolCallNameAndInput(call)
|
||||
if name == "" || allowed[name] == "" || input == nil {
|
||||
return nil
|
||||
}
|
||||
id := call.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
|
||||
}
|
||||
return []ai.ToolCall{{ID: id, Name: name, Input: input}}
|
||||
return []ai.ToolCall{{ID: id, Name: allowed[name], Input: input}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = textToolArguments(call.Arguments)
|
||||
}
|
||||
if call.Function != nil {
|
||||
fnName, fnInput := textToolCallNameAndInput(*call.Function)
|
||||
if name == "" {
|
||||
name = fnName
|
||||
}
|
||||
if input == nil {
|
||||
input = fnInput
|
||||
}
|
||||
}
|
||||
return name, input
|
||||
}
|
||||
|
||||
func textToolArguments(raw any) map[string]any {
|
||||
switch args := raw.(type) {
|
||||
case map[string]any:
|
||||
return args
|
||||
case string:
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &input); err == nil {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
|
||||
var out []ai.ToolCall
|
||||
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
tag, body := match[1], strings.TrimSpace(match[2])
|
||||
if calls := decodeTextToolCalls(body, allowed); len(calls) > 0 {
|
||||
out = append(out, calls...)
|
||||
continue
|
||||
}
|
||||
if calls := decodeTaggedTextToolCalls(body, allowed); len(calls) > 0 {
|
||||
out = append(out, calls...)
|
||||
continue
|
||||
}
|
||||
name := taggedToolName(tag)
|
||||
if name == "" || allowed[name] == "" {
|
||||
continue
|
||||
}
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ai.ToolCall{
|
||||
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_")),
|
||||
Name: allowed[name],
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
out = append(out, ai.ToolCall{
|
||||
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
|
||||
Name: canonical,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func functionCallBodies(text, name string) []string {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var bodies []string
|
||||
for searchFrom := 0; searchFrom < len(text); {
|
||||
idx := strings.Index(text[searchFrom:], name)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
start := searchFrom + idx
|
||||
open := start + len(name)
|
||||
if !isFunctionCallBoundary(text, start, open) {
|
||||
searchFrom = start + len(name)
|
||||
continue
|
||||
}
|
||||
bodyStart := open + 1
|
||||
bodyEnd, ok := balancedJSONObjectEnd(text, bodyStart)
|
||||
if !ok {
|
||||
searchFrom = bodyStart
|
||||
continue
|
||||
}
|
||||
bodies = append(bodies, strings.TrimSpace(text[bodyStart:bodyEnd]))
|
||||
searchFrom = bodyEnd + 1
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func isFunctionCallBoundary(text string, start, open int) bool {
|
||||
if open >= len(text) || text[open] != '(' {
|
||||
return false
|
||||
}
|
||||
if start > 0 {
|
||||
prev := text[start-1]
|
||||
if prev == '_' || prev == '.' || prev == '-' || prev == '$' || ('0' <= prev && prev <= '9') || ('A' <= prev && prev <= 'Z') || ('a' <= prev && prev <= 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := open + 1; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
continue
|
||||
case '{':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func balancedJSONObjectEnd(text string, start int) (int, bool) {
|
||||
for start < len(text) {
|
||||
switch text[start] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
start++
|
||||
case '{':
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := start; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if c == '\\' {
|
||||
escaped = true
|
||||
} else if c == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inString = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
for j := i + 1; j < len(text); j++ {
|
||||
switch text[j] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
continue
|
||||
case ')':
|
||||
return i + 1, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestParseTextToolCallsMiniMaxTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
|
||||
reply := `<tool_calls>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Design"}}</tool_call>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Build"}}</tool_call>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Ship"}}</tool_call>
|
||||
</tool_calls>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 3: %+v", len(calls), calls)
|
||||
}
|
||||
for i, want := range []string{"Design", "Build", "Ship"} {
|
||||
if calls[i].Name != "task_TaskService_Add" {
|
||||
t.Fatalf("call %d name = %q, want task_TaskService_Add", i, calls[i].Name)
|
||||
}
|
||||
if got := calls[i].Input["title"]; got != want {
|
||||
t.Fatalf("call %d title = %v, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
|
||||
reply := `<function=task_TaskService_Add>{"title":"Design"}</function>`
|
||||
|
||||
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["title"]; got != "Design" {
|
||||
t.Fatalf("title = %v, want Design", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add", OriginalName: "task.TaskService.Add"}}
|
||||
reply := `<tool_call>{"name":"task_TaskService_Create","arguments":{"title":"Design"}}</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 != "task_TaskService_Add" {
|
||||
t.Fatalf("call name = %q, want canonical task_TaskService_Add", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["title"]; got != "Design" {
|
||||
t.Fatalf("title = %v, want Design", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsOpenAICompatibleFunctionArgumentsString(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call>{"id":"call-2","type":"function","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}</tool_call>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "delegate" {
|
||||
t.Fatalf("call name = %q, want delegate", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
|
||||
t.Fatalf("task = %v, want summarize the conformance marker", got)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsTaggedMarkupWithSpacedNameAttribute(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call name = "delegate">{"task":"summarize the conformance marker","to":"blocked-reviewer"}</tool_call>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "delegate" {
|
||||
t.Fatalf("call name = %q, want delegate", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsHTMLEscapedTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call name="delegate">{"task":"summarize the conformance marker","to":"blocked-reviewer"}</tool_call>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "delegate" {
|
||||
t.Fatalf("call name = %q, want delegate", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
|
||||
t.Fatalf("task = %v, want summarize the conformance marker", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionCallSyntax(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `I will now call delegate({"task":"summarize the conformance marker","to":"blocked-reviewer"}) before answering.`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "delegate" {
|
||||
t.Fatalf("call name = %q, want delegate", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
|
||||
t.Fatalf("task = %v, want summarize the conformance marker", got)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionCallSyntaxHandlesNestedJSON(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `delegate({
|
||||
"task":"summarize the {escaped} marker",
|
||||
"meta":{"note":"paren ) and brace } in string"},
|
||||
"to":"blocked-reviewer"
|
||||
})`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the {escaped} marker" {
|
||||
t.Fatalf("task = %v, want nested JSON-safe task", got)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
+110
-3
@@ -2,6 +2,7 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -17,6 +18,8 @@ func init() {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("anthropic")
|
||||
ai.RegisterToolStream("anthropic")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
@@ -156,9 +159,113 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
// Stream generates a streaming response from Anthropic's Messages SSE API.
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": threadAnthropicMessages(req),
|
||||
"stream": true,
|
||||
}
|
||||
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, "/") + "/v1/messages"
|
||||
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-api-key", p.opts.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
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, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(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:"))
|
||||
var chunk struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
Message struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
Usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
switch chunk.Type {
|
||||
case "content_block_delta":
|
||||
if chunk.Delta.Type == "text_delta" && chunk.Delta.Text != "" {
|
||||
return &ai.Response{Reply: chunk.Delta.Text}, nil
|
||||
}
|
||||
case "message_start":
|
||||
if chunk.Message.Usage.InputTokens > 0 || chunk.Message.Usage.OutputTokens > 0 {
|
||||
return &ai.Response{Usage: usage(chunk.Message.Usage.InputTokens, chunk.Message.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_delta":
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: usage(chunk.Usage.InputTokens, chunk.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_stop":
|
||||
return nil, io.EOF
|
||||
case "error":
|
||||
return nil, fmt.Errorf("anthropic stream error: %s", data)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
func usage(input, output int) ai.Usage {
|
||||
return ai.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output}
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
@@ -191,7 +298,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
|
||||
@@ -3,6 +3,10 @@ package anthropic
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -81,15 +85,67 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
t.Fatalf("path = %q, want /v1/messages", r.URL.Path)
|
||||
}
|
||||
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-api-key"); got != "test-key" {
|
||||
t.Fatalf("x-api-key = %q, want test-key", got)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"stream":true`) {
|
||||
t.Fatalf("request body %s does not enable streaming", string(body))
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("event: message_start\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":2}}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hel"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_delta","usage":{"output_tokens":3}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_stop\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_stop"}` + "\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
stream, err := p.Stream(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var reply strings.Builder
|
||||
var usage ai.Usage
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Recv failed: %v", err)
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
if chunk.Usage.TotalTokens > 0 {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
}
|
||||
if got := reply.String(); got != "hello" {
|
||||
t.Fatalf("reply = %q, want hello", got)
|
||||
}
|
||||
if usage.TotalTokens != 3 {
|
||||
t.Fatalf("usage = %+v, want total 3", usage)
|
||||
}
|
||||
}
|
||||
|
||||
+518
-38
@@ -24,6 +24,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -93,20 +94,9 @@ func (p *Provider) Options() ai.Options { return p.opts }
|
||||
func (p *Provider) String() string { return "atlascloud" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
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},
|
||||
@@ -117,6 +107,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
if compatPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": compatPrompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
@@ -132,7 +125,45 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
|
||||
apiReq["tools"] = compatTools
|
||||
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
|
||||
}
|
||||
if 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
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
@@ -140,44 +171,105 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
var allToolCalls []ai.ToolCall
|
||||
var toolResults []string
|
||||
pendingToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
|
||||
for _, tc := range resp.ToolCalls {
|
||||
content := p.opts.ToolHandler(ctx, tc).Content
|
||||
if content != "" {
|
||||
toolResults = append(toolResults, content)
|
||||
for attempt := 0; len(pendingToolCalls) > 0 && attempt < 4; attempt++ {
|
||||
for _, tc := range pendingToolCalls {
|
||||
result := p.opts.ToolHandler(ctx, tc)
|
||||
if result.Refused != "" {
|
||||
tc.Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
tc.Result = result.Content
|
||||
toolResults = append(toolResults, result.Content)
|
||||
}
|
||||
allToolCalls = append(allToolCalls, tc)
|
||||
resp.ToolCalls = allToolCalls
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
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": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
"role": "assistant",
|
||||
"content": followUpRawMessage["content"],
|
||||
"tool_calls": followUpRawMessage["tool_calls"],
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
} else if len(toolResults) > 0 {
|
||||
resp.Answer = strings.Join(toolResults, "\n")
|
||||
pendingToolCalls = followUpResp.ToolCalls
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -289,6 +381,33 @@ func (s *atlasStream) Close() error {
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
type atlascloudAPIError struct {
|
||||
Status string
|
||||
Code int
|
||||
Retry time.Duration
|
||||
Phase string
|
||||
Summary string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *atlascloudAPIError) Error() string {
|
||||
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
|
||||
}
|
||||
|
||||
func (e *atlascloudAPIError) StatusCode() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func (e *atlascloudAPIError) RetryAfter() time.Duration {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.Retry
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -312,7 +431,12 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
|
||||
retryAfter := time.Duration(0)
|
||||
var retryErr interface{ RetryAfter() time.Duration }
|
||||
if errors.As(ai.NewHTTPError(httpResp, respBody), &retryErr) {
|
||||
retryAfter = retryErr.RetryAfter()
|
||||
}
|
||||
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, Code: httpResp.StatusCode, Retry: retryAfter, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
@@ -357,6 +481,362 @@ 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 {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": normalizeAtlasCloudSchema(t.Properties),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudSchema(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(schema))
|
||||
for k, v := range schema {
|
||||
out[k] = normalizeAtlasCloudSchemaValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudSchemaValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val)+1)
|
||||
for k, nested := range val {
|
||||
out[k] = normalizeAtlasCloudSchemaValue(nested)
|
||||
}
|
||||
if typ, _ := out["type"].(string); typ == "array" {
|
||||
if _, ok := out["items"]; !ok {
|
||||
out["items"] = map[string]any{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, nested := range val {
|
||||
out[i] = normalizeAtlasCloudSchemaValue(nested)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
|
||||
@@ -289,6 +289,596 @@ func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateNormalizesBuiltInToolSchemas(t *testing.T) {
|
||||
var body map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
planProperties := map[string]any{
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "ordered plan steps",
|
||||
},
|
||||
}
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
)
|
||||
_, 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: planProperties},
|
||||
{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)
|
||||
}
|
||||
|
||||
tools := body["tools"].([]any)
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("tools = %d, want custom tool plus built-ins", len(tools))
|
||||
}
|
||||
planTool := tools[1].(map[string]any)
|
||||
fn := planTool["function"].(map[string]any)
|
||||
params := fn["parameters"].(map[string]any)
|
||||
props := params["properties"].(map[string]any)
|
||||
steps := props["steps"].(map[string]any)
|
||||
if _, ok := steps["items"].(map[string]any); !ok {
|
||||
t.Fatalf("plan steps schema = %#v, want array items for AtlasCloud/minimax", steps)
|
||||
}
|
||||
if _, mutated := planProperties["steps"].(map[string]any)["items"]; mutated {
|
||||
t.Fatalf("Generate mutated caller tool schema: %#v", planProperties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateExecutesFollowUpToolCall(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:
|
||||
_, _ = 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))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var sawEcho, sawDelegate bool
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
switch call.Name {
|
||||
case "conformance_echo":
|
||||
sawEcho = true
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
case "delegate":
|
||||
sawDelegate = true
|
||||
return ai.ToolResult{ID: call.ID, Refused: ai.RefusedApproval, Content: "blocked by policy"}
|
||||
default:
|
||||
t.Fatalf("unexpected tool call %+v", call)
|
||||
return ai.ToolResult{}
|
||||
}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "run conformance",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": 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 !sawEcho || !sawDelegate {
|
||||
t.Fatalf("sawEcho=%v sawDelegate=%v, want both tools executed", sawEcho, sawDelegate)
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want echo and delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("follow-up delegate = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
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) {
|
||||
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:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"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.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if call.Name != "conformance_echo" {
|
||||
t.Fatalf("unexpected structured tool call %+v", call)
|
||||
}
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "run conformance",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": 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 tagged delegate follow-up for agent text fallback", resp.Reply)
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
t.Fatalf("Answer = %q, want follow-up text preserved only as Reply", resp.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -298,7 +888,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
ai.WithModel("deepseek-ai/DeepSeek-V3-0324"),
|
||||
)
|
||||
_, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "call a tool",
|
||||
@@ -312,7 +902,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=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
|
||||
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=deepseek-ai/DeepSeek-V3-0324", "tools=1", "tool_names=conformance_echo"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
|
||||
+24
-5
@@ -23,6 +23,10 @@ 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.
|
||||
@@ -31,12 +35,14 @@ 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,
|
||||
Model: hasModel,
|
||||
Image: hasImage,
|
||||
Video: hasVideo,
|
||||
Stream: hasStream,
|
||||
ToolStream: hasToolStream,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +64,9 @@ 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 {
|
||||
@@ -88,10 +97,18 @@ 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", or empty for the union of all
|
||||
// kind may be "model", "image", "video", "stream", "tool_stream", or empty for the union of all
|
||||
// provider registries.
|
||||
func RegisteredProviders(kind string) []string {
|
||||
names := map[string]struct{}{}
|
||||
@@ -121,6 +138,8 @@ func RegisteredProviders(kind string) []string {
|
||||
add(providers)
|
||||
case "stream":
|
||||
add(streamProviders)
|
||||
case "tool_stream":
|
||||
add(toolStreamProviders)
|
||||
case "image":
|
||||
add(imageProviders)
|
||||
case "video":
|
||||
|
||||
+23
-9
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"atlascloud", "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}},
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: 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}},
|
||||
{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}},
|
||||
}
|
||||
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}) {
|
||||
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true, ToolStream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
|
||||
@@ -90,8 +90,22 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"atlascloud", "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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var geminiResp struct {
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("groq")
|
||||
ai.RegisterToolStream("groq")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -147,7 +148,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
|
||||
@@ -30,6 +30,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("minimax")
|
||||
ai.RegisterToolStream("minimax")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -147,7 +148,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
|
||||
@@ -30,6 +30,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("mistral")
|
||||
ai.RegisterToolStream("mistral")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -147,7 +148,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
|
||||
@@ -45,6 +45,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("ollama")
|
||||
ai.RegisterToolStream("ollama")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Ollama.
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("openai")
|
||||
ai.RegisterToolStream("openai")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
@@ -285,7 +286,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
|
||||
+82
-1
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -19,6 +21,64 @@ type RetryAfterCoder interface {
|
||||
RetryAfter() time.Duration
|
||||
}
|
||||
|
||||
// HTTPError describes a failed provider HTTP response while preserving the
|
||||
// status code and Retry-After signal for retry classifiers.
|
||||
type HTTPError struct {
|
||||
Status string
|
||||
Code int
|
||||
Body string
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("API error (%s): %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
func (e *HTTPError) StatusCode() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func (e *HTTPError) RetryAfter() time.Duration {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return parseRetryAfter(e.Header.Get("Retry-After"), time.Now())
|
||||
}
|
||||
|
||||
func NewHTTPError(resp *http.Response, body []byte) error {
|
||||
if resp == nil {
|
||||
return errors.New("API error: nil response")
|
||||
}
|
||||
return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: string(body), Header: resp.Header.Clone()}
|
||||
}
|
||||
|
||||
func parseRetryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if seconds, err := strconv.Atoi(value); err == nil {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
when, err := http.ParseTime(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if delay := when.Sub(now); delay > 0 {
|
||||
return delay
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ErrorKind classifies provider-boundary failures into stable buckets callers
|
||||
// can inspect without parsing provider-specific error strings.
|
||||
type ErrorKind string
|
||||
@@ -97,7 +157,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
info.MaxAttempts = policy.MaxAttempts
|
||||
callCtx = WithRunInfo(callCtx, info)
|
||||
}
|
||||
resp, err := m.Generate(callCtx, req, opts...)
|
||||
resp, err := generateAttempt(callCtx, m, req, opts...)
|
||||
cancel()
|
||||
|
||||
// Caller cancellation/deadline always wins and is not retried, even if
|
||||
@@ -137,6 +197,27 @@ 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 {
|
||||
backoff := base
|
||||
if backoff <= 0 {
|
||||
|
||||
+162
-4
@@ -3,6 +3,8 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -68,9 +70,9 @@ func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
attempts := 0
|
||||
var attempts atomic.Int32
|
||||
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
attempts.Add(1)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}}
|
||||
@@ -90,8 +92,8 @@ func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
if got := attempts.Load(); got != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +136,34 @@ 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" }
|
||||
@@ -221,3 +251,131 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
|
||||
{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"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
@@ -256,7 +257,7 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"anthropic", "gemini"} {
|
||||
for _, provider := range []string{"gemini"} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities(provider); caps.Stream {
|
||||
|
||||
@@ -30,6 +30,7 @@ func init() {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("together")
|
||||
ai.RegisterToolStream("together")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -147,7 +148,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
|
||||
+22
-1
@@ -52,6 +52,27 @@ This starts:
|
||||
|
||||
Open http://localhost:8080 to see your services and call them from the browser.
|
||||
|
||||
Call the generated service from another terminal:
|
||||
|
||||
```
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
## First agent on-ramp
|
||||
|
||||
Once the scaffold → run → call path works, ask the installed CLI for the
|
||||
provider-free agent path:
|
||||
|
||||
```
|
||||
micro agent demo
|
||||
micro examples
|
||||
micro zero-to-hero
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
@@ -663,7 +684,7 @@ micro loop init \
|
||||
```
|
||||
|
||||
- `--roles`: which roles to scaffold (`planner,builder,triage`, or `all`)
|
||||
- `--agent`: how the workflows summon the agent (an `@mention`)
|
||||
- `--agent`: how the workflows summon the agent — any `@mention`-driven coding agent (e.g. `@codex`, `@claude`)
|
||||
- `--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
|
||||
|
||||
+30
-18
@@ -74,7 +74,9 @@ 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 --provider openai --prompt "list all users"
|
||||
micro chat assistant --prompt "create a task"`,
|
||||
ArgsUsage: "[agent]",
|
||||
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"}},
|
||||
@@ -316,6 +318,7 @@ 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)
|
||||
@@ -323,15 +326,36 @@ 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(
|
||||
@@ -343,17 +367,8 @@ func run(c *cli.Context) error {
|
||||
agent.APIKey(apiKey),
|
||||
)
|
||||
|
||||
s := &session{
|
||||
provider: provider,
|
||||
apiKey: apiKey,
|
||||
tools: tools,
|
||||
reg: reg,
|
||||
cl: cl,
|
||||
hist: ai.NewHistory(50),
|
||||
builtinTools: builtinTools,
|
||||
builtinHandle: builtinHandle,
|
||||
stream: streamOutput,
|
||||
}
|
||||
s.builtinTools = builtinTools
|
||||
s.builtinHandle = builtinHandle
|
||||
s.refreshTools()
|
||||
|
||||
// Wrap the tool handler to intercept generate calls
|
||||
@@ -387,9 +402,6 @@ func run(c *cli.Context) error {
|
||||
|
||||
defer s.cleanup()
|
||||
|
||||
// Discover registered agents
|
||||
hasAgents := s.discoverAgents()
|
||||
|
||||
if singlePrompt != "" {
|
||||
return s.ask(c.Context, singlePrompt)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,57 @@ import (
|
||||
"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
|
||||
|
||||
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
|
||||
configuring API keys. It runs the maintained support/first-agent transcript with
|
||||
the deterministic mock model used by CI:
|
||||
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
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
|
||||
|
||||
After it passes:
|
||||
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
- Diagnose provider-backed chat: https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
- Walk the full 0→hero lifecycle: https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
|
||||
Use live-provider chat when you are ready for real model behavior:
|
||||
micro agent preflight # before micro run: prerequisites
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor # after micro run: chat/gateway/inspect recovery
|
||||
micro inspect agent <name>`
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "runs",
|
||||
@@ -34,16 +85,48 @@ func init() {
|
||||
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents",
|
||||
Usage: "Manage AI agents (try: micro agent demo)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "preflight",
|
||||
Aliases: []string{"doctor"},
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Name: "demo",
|
||||
Usage: "Show the no-secret first-agent demo command",
|
||||
Description: `Print the provider-free first-agent path for new developers:
|
||||
the deterministic mock-model transcript, when to use it, and where to go next
|
||||
for live-provider chat and inspect/debugging.`,
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, noSecretDemoHelp)
|
||||
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",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "doctor",
|
||||
Usage: "Diagnose chat, gateway, registration, provider, and inspect recovery after micro run",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
@@ -179,14 +262,44 @@ 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: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the input-required run\n", 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 {
|
||||
|
||||
@@ -59,6 +59,46 @@ 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.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 TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
events := []goagent.RunEvent{{
|
||||
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type doctorDeps struct {
|
||||
getenv func(string) string
|
||||
httpGet func(string) (*http.Response, error)
|
||||
listServices func() ([]*registry.Service, error)
|
||||
getService func(string) ([]*registry.Service, error)
|
||||
listRuns func(string) ([]goagent.RunSummary, error)
|
||||
}
|
||||
|
||||
func defaultDoctorDeps() doctorDeps {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
return doctorDeps{
|
||||
getenv: defaultPreflightDeps().getenv,
|
||||
httpGet: client.Get,
|
||||
listServices: registry.ListServices,
|
||||
getService: registry.GetService,
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
|
||||
if gateway == "" {
|
||||
gateway = "http://localhost:8080"
|
||||
}
|
||||
gateway = strings.TrimRight(gateway, "/")
|
||||
checks := agentDoctorChecks(deps, gateway)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent recovery doctor")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
if !check.OK && check.Next != "" {
|
||||
fmt.Fprintf(w, " Next: %s\n", check.Next)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = defaultPreflightDeps().getenv
|
||||
}
|
||||
if deps.httpGet == nil {
|
||||
deps.httpGet = http.Get
|
||||
}
|
||||
if deps.listServices == nil {
|
||||
deps.listServices = registry.ListServices
|
||||
}
|
||||
if deps.getService == nil {
|
||||
deps.getService = registry.GetService
|
||||
}
|
||||
if deps.listRuns == nil {
|
||||
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
}
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
|
||||
agents, regCheck := checkAgentRegistration(deps)
|
||||
checks = append(checks, regCheck)
|
||||
checks = append(checks, checkRunHistory(deps, agents))
|
||||
checks = append(checks, checkProviderConfig(deps))
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/agent")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
|
||||
}
|
||||
|
||||
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/api/agent/settings")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
|
||||
}
|
||||
var settings map[string]string
|
||||
_ = json.NewDecoder(resp.Body).Decode(&settings)
|
||||
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
|
||||
}
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
|
||||
}
|
||||
|
||||
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
|
||||
services, err := deps.listServices()
|
||||
if err != nil {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
|
||||
}
|
||||
var agents []string
|
||||
for _, svc := range services {
|
||||
records, err := deps.getService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
if serviceIsAgent(records[0]) {
|
||||
agents = append(agents, svc.Name)
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
|
||||
}
|
||||
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
|
||||
}
|
||||
|
||||
func serviceIsAgent(svc *registry.Service) bool {
|
||||
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
for _, node := range svc.Nodes {
|
||||
if node.Metadata != nil && node.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
|
||||
if len(agents) == 0 {
|
||||
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
for _, name := range agents {
|
||||
runs, err := deps.listRuns(name)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
|
||||
}
|
||||
}
|
||||
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
|
||||
}
|
||||
|
||||
func checkProviderConfig(deps doctorDeps) preflightCheck {
|
||||
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
|
||||
check.Name = "provider configuration"
|
||||
if !check.OK {
|
||||
check.Detail = "no provider key found for live LLM chat"
|
||||
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
|
||||
}
|
||||
return check
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
|
||||
return func(string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(key string) string {
|
||||
if key == "MICRO_AI_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "assistant"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
|
||||
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(string) string { return "" },
|
||||
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "greeter"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := runAgentDoctor(&out, deps, "http://localhost:8080")
|
||||
if err == nil {
|
||||
t.Fatal("runAgentDoctor() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"debugging-agents.html",
|
||||
"no-secret-first-agent.html",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("quickcheck output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-7
@@ -24,27 +24,88 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/remote"
|
||||
)
|
||||
|
||||
const zeroToHeroHelp = `0→hero no-secret lifecycle demo
|
||||
|
||||
Run this from a go-micro repository checkout when you want one command that
|
||||
proves the maintained services → agents → workflows path without provider keys:
|
||||
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
|
||||
That script runs the same deterministic path CI uses:
|
||||
- CLI discovery for scaffold, run, chat, inspect, flow runs, and deploy dry-run
|
||||
- the smallest first-agent example
|
||||
- the support-desk reference app with services, an agent, a flow, and an approval gate
|
||||
- plan/delegate and universe harnesses with only the model mocked
|
||||
|
||||
If you only want the runnable examples first:
|
||||
go run ./examples/first-agent
|
||||
go run ./examples/support
|
||||
|
||||
Full local contract:
|
||||
make harness
|
||||
|
||||
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:
|
||||
|
||||
1. Smallest service-backed agent
|
||||
go run ./examples/first-agent
|
||||
Proves an agent can call a service tool with the deterministic mock model.
|
||||
|
||||
2. No-secret support-agent transcript
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
Exercises service tools, mock-model chat, and inspectable run history.
|
||||
|
||||
3. Full services → agents → workflows reference app
|
||||
go run ./examples/support
|
||||
Shows the support desk service, agent, workflow, and approval gate together.
|
||||
|
||||
Then continue the same path with the installed CLI:
|
||||
micro agent demo
|
||||
micro docs
|
||||
micro zero-to-hero
|
||||
|
||||
Guides:
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html`
|
||||
|
||||
const docsWayfinding = `First-agent and 0→hero docs:
|
||||
|
||||
1. No-secret first-agent transcript
|
||||
1. Start with the no-secret CLI demo
|
||||
micro agent demo
|
||||
This prints the maintained support-agent transcript command so you can
|
||||
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:
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
2. Your First Agent
|
||||
3. Your First Agent
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
Build a service-backed agent, then use:
|
||||
micro agent preflight
|
||||
micro agent preflight # before micro run: prerequisites
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor # after micro run: chat/gateway/inspect recovery
|
||||
|
||||
3. Debugging your agent
|
||||
4. Debugging your agent
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
Inspect agent runs and memory with:
|
||||
micro inspect agent
|
||||
micro runs <agent>
|
||||
micro agent doctor
|
||||
micro inspect agent <name>
|
||||
micro agent history <name>
|
||||
|
||||
4. 0→hero Reference
|
||||
5. 0→hero Reference
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
|
||||
|
||||
@@ -120,6 +181,28 @@ func init() {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
Name: "examples",
|
||||
Usage: "Show provider-free first-agent example paths",
|
||||
Description: `Print the maintained no-secret examples for the services → agents →
|
||||
workflows on-ramp: first-agent, transcript, support app, and matching guides.`,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, examplesWayfinding)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "zero-to-hero",
|
||||
Usage: "Show the no-secret 0→hero lifecycle demo command",
|
||||
Description: `Print the maintained provider-free services → agents → workflows
|
||||
lifecycle command and the smaller runnable examples it covers.`,
|
||||
Aliases: []string{"hero"},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, zeroToHeroHelp)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Show the first-agent and 0→hero documentation path",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
@@ -30,7 +31,7 @@ func TestZeroToOneContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.assertLocalModule(t)
|
||||
generated.build(t)
|
||||
generated.run(t)
|
||||
generated.call(t, "Alice", "Hello Alice")
|
||||
@@ -51,12 +52,50 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.assertLocalModule(t)
|
||||
generated.build(t)
|
||||
generated.run(t)
|
||||
generated.call(t, "Bob", "Hello Bob")
|
||||
}
|
||||
|
||||
func TestPrintNextStepsSurfacesFirstAgentPath(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "helloworld", false)
|
||||
|
||||
for _, want := range []string{
|
||||
"cd helloworld",
|
||||
"micro agent preflight",
|
||||
"go run .",
|
||||
"micro chat",
|
||||
"micro inspect agent <name>",
|
||||
"micro agent demo",
|
||||
"micro docs",
|
||||
"your-first-agent.html",
|
||||
"zero-to-hero.html",
|
||||
"http://localhost:3001/mcp/tools",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintNextStepsNoMCPSkipsMCPHints(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "worker", true)
|
||||
|
||||
for _, want := range []string{"micro agent preflight", "micro chat", "micro inspect agent <name>", "micro agent demo", "micro docs"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("--no-mcp next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{"http://localhost:3001/mcp/tools", "micro mcp serve"} {
|
||||
if strings.Contains(out.String(), notWant) {
|
||||
t.Fatalf("--no-mcp next steps should not include %q:\n%s", notWant, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
dir string
|
||||
repoRoot string
|
||||
@@ -73,6 +112,7 @@ 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()
|
||||
@@ -103,7 +143,7 @@ func generateService(t *testing.T, name string, args ...string) generatedService
|
||||
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
func (g generatedService) replaceModule(t *testing.T) {
|
||||
func (g generatedService) assertLocalModule(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
modPath := filepath.Join(g.dir, "go.mod")
|
||||
@@ -111,10 +151,9 @@ func (g generatedService) replaceModule(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/build"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -38,6 +39,8 @@ 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
|
||||
@@ -68,6 +71,10 @@ 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
|
||||
@@ -214,6 +221,7 @@ func Run(ctx *cli.Context) error {
|
||||
GoPath: goPath,
|
||||
UseGoPath: false,
|
||||
MicroVersion: microVersion(),
|
||||
MicroReplace: microReplace(),
|
||||
}
|
||||
|
||||
if useProto {
|
||||
@@ -280,18 +288,31 @@ func Run(ctx *cli.Context) error {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
|
||||
fmt.Println(" Next steps:")
|
||||
fmt.Printf(" cd %s\n", dir)
|
||||
fmt.Println(" go run .")
|
||||
if !noMCP {
|
||||
fmt.Println()
|
||||
fmt.Printf(" MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Println(" Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
}
|
||||
fmt.Println()
|
||||
printNextSteps(os.Stdout, dir, noMCP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printNextSteps(w io.Writer, dir string, noMCP bool) {
|
||||
fmt.Fprintln(w, " Next steps:")
|
||||
fmt.Fprintf(w, " cd %s\n", dir)
|
||||
fmt.Fprintln(w, " micro agent preflight")
|
||||
fmt.Fprintln(w, " go run .")
|
||||
fmt.Fprintln(w, " micro chat")
|
||||
fmt.Fprintln(w, " micro inspect agent <name>")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, " First-agent path:")
|
||||
fmt.Fprintln(w, " micro agent demo")
|
||||
fmt.Fprintln(w, " micro docs")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/your-first-agent.html")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/zero-to-hero.html")
|
||||
if !noMCP {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Fprintln(w, " Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
|
||||
switch name {
|
||||
case "crud":
|
||||
|
||||
@@ -10,7 +10,9 @@ 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
|
||||
@@ -20,5 +22,7 @@ require (
|
||||
go 1.23
|
||||
|
||||
require go-micro.dev/v6 {{.MicroVersion}}
|
||||
`
|
||||
{{if .MicroReplace}}
|
||||
replace go-micro.dev/v6 => {{.MicroReplace}}
|
||||
{{end}}`
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func TestExamplesWayfindingIndexStaysLinked(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
files := map[string]string{}
|
||||
for _, name := range []string{"README.md", "examples/README.md", "examples/INDEX.md"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
files[name] = string(b)
|
||||
}
|
||||
|
||||
for _, check := range []struct {
|
||||
file string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
file: "README.md",
|
||||
want: []string{"examples/INDEX.md", "examples/first-agent/", "examples/support/", "zero-to-hero.md"},
|
||||
},
|
||||
{
|
||||
file: "examples/README.md",
|
||||
want: []string{"./INDEX.md", "./first-agent/", "./support/", "./mcp/hello/", "./mcp/workflow/"},
|
||||
},
|
||||
{
|
||||
file: "examples/INDEX.md",
|
||||
want: []string{"go run ./examples/first-agent", "go run ./examples/support", "mcp/hello", "mcp/workflow", "flow-durable", "micro examples"},
|
||||
},
|
||||
} {
|
||||
for _, want := range check.want {
|
||||
if !strings.Contains(files[check.file], want) {
|
||||
t.Fatalf("%s missing %q", check.file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExamplesCommandPointsAtWayfindingIndex(t *testing.T) {
|
||||
examples := commandByName(t, "examples")
|
||||
var out bytes.Buffer
|
||||
app := cli.NewApp()
|
||||
app.Writer = &out
|
||||
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro examples failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"examples/INDEX.md",
|
||||
"go run ./examples/first-agent",
|
||||
"go run ./examples/support",
|
||||
"micro zero-to-hero",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
_ = microcmd.DefaultCmd // keep this test coupled to the registered command package.
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -22,7 +24,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs"} {
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs", "examples"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
@@ -30,12 +32,21 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if !subcommands["agent"]["preflight"] {
|
||||
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
|
||||
}
|
||||
if !subcommands["agent"]["demo"] {
|
||||
t.Fatal("first-agent walkthrough missing no-secret boundary: agent demo")
|
||||
}
|
||||
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") {
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") || !strings.Contains(chat.Description, `micro chat assistant --prompt`) {
|
||||
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
|
||||
}
|
||||
|
||||
@@ -49,20 +60,239 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro docs failed: %v", err)
|
||||
}
|
||||
if demoIdx, guideIdx := strings.Index(out.String(), "micro agent demo"), strings.Index(out.String(), "no-secret-first-agent.html"); demoIdx < 0 || guideIdx < 0 || demoIdx > guideIdx {
|
||||
t.Fatalf("micro docs should lead with micro agent demo before guide links:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"micro agent demo",
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
"micro agent preflight",
|
||||
"micro agent preflight # before micro run: prerequisites",
|
||||
"micro run",
|
||||
"micro chat",
|
||||
"micro inspect agent",
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
|
||||
"micro inspect agent <name>",
|
||||
"micro agent history <name>",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "micro runs") {
|
||||
t.Fatalf("micro docs output should use the first-agent inspect command, not the legacy runs shortcut:\n%s", out.String())
|
||||
}
|
||||
|
||||
examples := commandByName(t, "examples")
|
||||
if !strings.Contains(examples.Usage, "first-agent") {
|
||||
t.Fatalf("micro examples should advertise the first-agent examples path; usage was %q", examples.Usage)
|
||||
}
|
||||
out.Reset()
|
||||
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro examples failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"First-agent examples",
|
||||
"go run ./examples/first-agent",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
|
||||
"go run ./examples/support",
|
||||
"micro agent demo",
|
||||
"micro docs",
|
||||
"micro zero-to-hero",
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
agent := commandByName(t, "agent")
|
||||
if !strings.Contains(agent.Usage, "micro agent demo") {
|
||||
t.Fatalf("micro agent help should advertise the no-secret demo; usage was %q", agent.Usage)
|
||||
}
|
||||
doctor := subcommandByName(t, agent, "doctor")
|
||||
for _, want := range []string{"chat", "gateway", "registration", "provider", "inspect", "after micro run"} {
|
||||
if !strings.Contains(doctor.Usage, want) {
|
||||
t.Fatalf("micro agent doctor usage should advertise after-run recovery for %q; usage was %q", want, doctor.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"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 {
|
||||
t.Fatalf("micro agent demo failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"No-secret first-agent demo",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
|
||||
"provider-free",
|
||||
"micro agent preflight # before micro run: prerequisites",
|
||||
"micro chat",
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
|
||||
"micro inspect agent <name>",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro agent demo output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"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",
|
||||
"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 {
|
||||
@@ -75,3 +305,14 @@ func commandByName(t *testing.T, name string) *cli.Command {
|
||||
t.Fatalf("missing command %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func subcommandByName(t *testing.T, command *cli.Command, name string) *cli.Command {
|
||||
t.Helper()
|
||||
for _, subcommand := range command.Subcommands {
|
||||
if subcommand.Name == name {
|
||||
return subcommand
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing subcommand %q under %q", name, command.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -98,16 +98,25 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
|
||||
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
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)
|
||||
}
|
||||
writeAgentRunBreadcrumbs(w, name, run)
|
||||
}
|
||||
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: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the input-required run\n", 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":
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
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")`} {
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro agent history support run-1`, `micro.AgentResume(ctx, agent, "run-1")`, `micro.ResumeStreamAsk(ctx, agent, "run-1")`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -31,11 +31,14 @@ func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
|
||||
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro agent history support run-input`, `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) {
|
||||
|
||||
@@ -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 (an @mention)", Value: "@codex"},
|
||||
&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: "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 * * * *"},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: "Loop: Release"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
@@ -66,11 +68,30 @@ jobs:
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
|
||||
esac
|
||||
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
|
||||
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
|
||||
|
||||
# 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)"
|
||||
|
||||
git config user.name "loop release bot"
|
||||
git config user.email "noreply@users.noreply.github.com"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT."
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
@@ -19,7 +21,7 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy"} {
|
||||
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy", "zero-to-hero"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("missing %q command", want)
|
||||
}
|
||||
@@ -48,3 +50,29 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
|
||||
t.Fatal("missing deploy boundary: deploy --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroToHeroCommandPrintsMaintainedNoSecretPath(t *testing.T) {
|
||||
app := microcmd.DefaultCmd.App()
|
||||
var out bytes.Buffer
|
||||
oldWriter := app.Writer
|
||||
app.Writer = &out
|
||||
t.Cleanup(func() { app.Writer = oldWriter })
|
||||
|
||||
if err := app.Run([]string{"micro", "zero-to-hero"}); err != nil {
|
||||
t.Fatalf("micro zero-to-hero failed: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"0→hero no-secret lifecycle demo",
|
||||
"./internal/harness/zero-to-hero-ci/run.sh",
|
||||
"go run ./examples/first-agent",
|
||||
"go run ./examples/support",
|
||||
"make harness",
|
||||
"services → agents → workflows",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("micro zero-to-hero output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -14,8 +14,10 @@ import (
|
||||
|
||||
type config struct {
|
||||
// the current values
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
// the current snapshot
|
||||
snap *loader.Snapshot
|
||||
opts Options
|
||||
@@ -48,6 +50,9 @@ func (c *config) Init(opts ...Option) error {
|
||||
Reader: json.NewReader(),
|
||||
}
|
||||
c.exit = make(chan bool)
|
||||
c.closeMu.Lock()
|
||||
c.closed = false
|
||||
c.closeMu.Unlock()
|
||||
for _, o := range opts {
|
||||
o(&c.opts)
|
||||
}
|
||||
@@ -184,12 +189,15 @@ func (c *config) Sync() error {
|
||||
}
|
||||
|
||||
func (c *config) Close() error {
|
||||
select {
|
||||
case <-c.exit:
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return nil
|
||||
default:
|
||||
close(c.exit)
|
||||
}
|
||||
|
||||
close(c.exit)
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -45,6 +46,30 @@ func createFileForTest(t *testing.T) *os.File {
|
||||
return fh
|
||||
}
|
||||
|
||||
func TestConfigCloseConcurrentIdempotent(t *testing.T) {
|
||||
conf, err := NewConfig(WithWatcherDisabled())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error but got %v", err)
|
||||
}
|
||||
|
||||
const goroutines = 64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := conf.Close(); err != nil {
|
||||
t.Errorf("Expected close to be idempotent but got %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if err := conf.Close(); err != nil {
|
||||
t.Fatalf("Expected repeated close to be idempotent but got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadWithGoodFile(t *testing.T) {
|
||||
fh := createFileForTest(t)
|
||||
path := fh.Name()
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
|
||||
type memory struct {
|
||||
// the current values
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
// the current snapshot
|
||||
snap *loader.Snapshot
|
||||
|
||||
@@ -270,12 +272,15 @@ func (m *memory) Sync() error {
|
||||
}
|
||||
|
||||
func (m *memory) Close() error {
|
||||
select {
|
||||
case <-m.exit:
|
||||
m.closeMu.Lock()
|
||||
defer m.closeMu.Unlock()
|
||||
|
||||
if m.closed {
|
||||
return nil
|
||||
default:
|
||||
close(m.exit)
|
||||
}
|
||||
|
||||
close(m.exit)
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Examples wayfinding
|
||||
|
||||
Use this index when you want the shortest path from a first runnable agent to the
|
||||
next services, agents, workflows, and interop examples. Every command below is
|
||||
provider-free unless the example README says otherwise.
|
||||
|
||||
## Pick by goal
|
||||
|
||||
| Goal | Start here | Run or verify | Then try |
|
||||
|------|------------|---------------|----------|
|
||||
| Run the smallest no-secret agent | [`first-agent`](./first-agent/) | `go run ./examples/first-agent` | [`agent-demo`](./agent-demo/) for a larger service-backed agent |
|
||||
| Prove the maintained 0→hero path | [`support`](./support/) | `go run ./examples/support` and `go test ./examples/support` | [`zero-to-hero` guide](../internal/website/docs/guides/zero-to-hero.md) |
|
||||
| See planning and delegation | [`agent-plan-delegate`](./agent-plan-delegate/) | `go run ./examples/agent-plan-delegate` | [`plan-delegate` guide](../internal/website/docs/guides/plan-delegate.md) |
|
||||
| Expose services through MCP | [`mcp/hello`](./mcp/hello/) | follow [`mcp`](./mcp/) setup | [`mcp/crud`](./mcp/crud/) and [`mcp/workflow`](./mcp/workflow/) |
|
||||
| Try A2A or gRPC interop next | [`agent-demo`](./agent-demo/) plus gateway docs | run the example, then use the gateway docs | [`grpc-interop`](./grpc-interop/) |
|
||||
| Add workflow durability | [`flow-durable`](./flow-durable/) | `go run ./examples/flow-durable` | [`flow-loop`](./flow-loop/) |
|
||||
|
||||
## Recommended adoption path
|
||||
|
||||
1. **First service:** run [`hello-world`](./hello-world/) to learn service
|
||||
registration, handlers, client calls, and health checks.
|
||||
2. **First agent:** run [`first-agent`](./first-agent/) with
|
||||
`go run ./examples/first-agent`; it uses a deterministic mock model and needs
|
||||
no provider key.
|
||||
3. **0→hero reference:** run [`support`](./support/) with
|
||||
`go run ./examples/support`; it keeps typed services, an agent chat loop, an
|
||||
event-driven flow, and an approval gate in one maintained example.
|
||||
4. **Interop next:** use [`mcp/hello`](./mcp/hello/), [`mcp/crud`](./mcp/crud/),
|
||||
and [`mcp/workflow`](./mcp/workflow/) when you are ready to expose tools to
|
||||
external AI clients.
|
||||
5. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
|
||||
needs checkpointed, resumable deterministic work.
|
||||
|
||||
## CLI wayfinding
|
||||
|
||||
The installed CLI prints the same path:
|
||||
|
||||
```bash
|
||||
micro examples
|
||||
micro agent demo
|
||||
micro zero-to-hero
|
||||
```
|
||||
|
||||
Keep this file, [`README.md`](../README.md), and the `micro examples` output in
|
||||
sync so new developers can find `examples/first-agent` and `examples/support`
|
||||
from one documented path.
|
||||
+5
-3
@@ -7,14 +7,16 @@ coordinate work with workflows.
|
||||
## Quick Start
|
||||
|
||||
Each example can be run with `go run .` from its directory unless its README says
|
||||
otherwise. If you are new to the repo, follow the first-agent path below instead
|
||||
of reading the directories alphabetically.
|
||||
otherwise. If you are new to the repo, start with the [examples wayfinding index](./INDEX.md)
|
||||
or follow the first-agent path below instead of reading the directories alphabetically.
|
||||
|
||||
## Recommended first-agent path
|
||||
|
||||
This path is the canonical services → agents → workflows route through the examples map. Debugging and observability wayfinding stays nearby once the first run works.
|
||||
|
||||
| Step | Start here | What you learn | Next step |
|
||||
|------|------------|----------------|-----------|
|
||||
| 1. First service | [`hello-world`](./hello-world/) | Create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
|
||||
| 1. First service | [`hello-world`](./hello-world/) | Build the 0→1 service path: create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
|
||||
| 2. First agent | [`first-agent`](./first-agent/) | Run the smallest service-backed agent with a deterministic mock model and no provider key. | Compare with [`agent-demo`](./agent-demo/) or the maintained 0-to-hero path in [`support`](./support/). |
|
||||
| 3. First workflow | [`support`](./support/) | Follow typed services into an agent chat loop, an event-driven `intake` flow, and an approval gate in one runnable reference. | Deepen the workflow model with [`flow-durable`](./flow-durable/). |
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
@@ -92,9 +93,17 @@ func runFirstAgent() error {
|
||||
ai.Register("first-agent-mock", newMock)
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
br := broker.NewMemoryBroker()
|
||||
if err := br.Init(); err != nil {
|
||||
return fmt.Errorf("init broker: %w", err)
|
||||
}
|
||||
if err := br.Connect(); err != nil {
|
||||
return fmt.Errorf("connect broker: %w", err)
|
||||
}
|
||||
defer br.Disconnect()
|
||||
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.HandleSignal(false))
|
||||
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(new(NotesService)); err != nil {
|
||||
return fmt.Errorf("handle notes: %w", err)
|
||||
}
|
||||
@@ -110,6 +119,7 @@ func runFirstAgent() error {
|
||||
agent.Provider("first-agent-mock"),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithClient(cl),
|
||||
agent.WithBroker(br),
|
||||
agent.WithStore(store.NewMemoryStore()),
|
||||
)
|
||||
agentErr := make(chan error, 1)
|
||||
|
||||
+35
-5
@@ -627,6 +627,9 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
|
||||
reply = err.Error()
|
||||
state = stateInputRequired
|
||||
}
|
||||
} else if strings.TrimSpace(reply) == "" {
|
||||
reply = "error: agent returned an empty response"
|
||||
state = stateFailed
|
||||
}
|
||||
task := d.taskFromReply(p.Message, reply, state)
|
||||
d.store(task)
|
||||
@@ -750,13 +753,11 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
|
||||
if err := g.opts.Client.Call(ctx, req, &rsp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if err := json.Unmarshal(rsp.Data, &out); err != nil {
|
||||
reply, err := decodeAgentChatReply(rsp.Data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Reply, nil
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -976,6 +977,35 @@ func textArtifact(text string) Artifact {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAgentChatReply(data []byte) (string, error) {
|
||||
var out struct {
|
||||
Reply string `json:"reply"`
|
||||
Answer string `json:"answer"`
|
||||
Content string `json:"content"`
|
||||
Text string `json:"text"`
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
out.Reply,
|
||||
out.Answer,
|
||||
out.Content,
|
||||
out.Text,
|
||||
out.Message.Content,
|
||||
out.Message.Text,
|
||||
} {
|
||||
if strings.TrimSpace(candidate) != "" {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// requestContext carries request cancellation and deadlines into the downstream
|
||||
// agent call without leaking HTTP transport context values into the go-micro
|
||||
// client stack.
|
||||
|
||||
@@ -523,6 +523,65 @@ func TestMessageStreamChunksFallsBackWhenUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStreamFallbackDoesNotCompleteWithEmptyText(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
d.serveWithStream(rr, req, func(context.Context, string) (string, error) {
|
||||
return "", nil
|
||||
}, func(context.Context, string) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
|
||||
})
|
||||
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
}
|
||||
if event.Error != nil {
|
||||
t.Fatalf("fallback event error: %+v", event.Error)
|
||||
}
|
||||
if event.Result.Status.State != stateFailed {
|
||||
t.Fatalf("fallback state = %q, want failed", event.Result.Status.State)
|
||||
}
|
||||
if got := textOf(event.Result.Artifacts[0].Parts); got == "" {
|
||||
t.Fatalf("fallback artifact text is empty: %+v", event.Result.Artifacts)
|
||||
}
|
||||
if got := textOf(event.Result.History[len(event.Result.History)-1].Parts); got == "" {
|
||||
t.Fatalf("fallback history text is empty: %+v", event.Result.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAgentChatReplyFallsBackToProviderTextFields(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"answer": `{"answer":"answer text"}`,
|
||||
"content": `{"content":"content text"}`,
|
||||
"text": `{"text":"text field"}`,
|
||||
"message_content": `{"message":{"content":"message content"}}`,
|
||||
"message_text": `{"message":{"text":"message text"}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := decodeAgentChatReply([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeAgentChatReply error: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatalf("decodeAgentChatReply(%s) returned empty text", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksResubscribeStreamsCurrentAndSubsequentEvents(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
initial := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateWorking, Timestamp: time.Now().UTC().Format(time.RFC3339)}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module go-micro.dev/v6
|
||||
|
||||
go 1.24
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.1
|
||||
toolchain go1.25.12
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
@@ -32,24 +32,23 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/stretchr/objx v0.5.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/test-go/testify v1.1.4
|
||||
github.com/urfave/cli/v2 v2.27.6
|
||||
github.com/xlab/treeprint v1.2.0
|
||||
go.etcd.io/bbolt v1.4.0
|
||||
go.etcd.io/etcd/api/v3 v3.5.21
|
||||
go.etcd.io/etcd/client/v3 v3.5.21
|
||||
go.opentelemetry.io/otel v1.35.0
|
||||
go.opentelemetry.io/otel/sdk v1.35.0
|
||||
go.opentelemetry.io/otel/trace v1.35.0
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sync v0.13.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463
|
||||
google.golang.org/grpc v1.71.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057
|
||||
google.golang.org/protobuf v1.36.6
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -63,7 +62,7 @@ require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
@@ -97,19 +96,20 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -65,8 +65,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
@@ -317,8 +317,8 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
@@ -345,8 +345,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
@@ -368,18 +368,18 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoB
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs=
|
||||
go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY=
|
||||
go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
@@ -408,8 +408,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@@ -418,8 +418,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -433,16 +433,16 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -473,8 +473,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -482,8 +482,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -499,24 +499,26 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
|
||||
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057 h1:lPv+iqlAyiKMjbL3ivJlAASixPknLv806R6zaoE4PUM=
|
||||
google.golang.org/grpc/examples v0.0.0-20250515150734-f2d3e11f3057/go.mod h1:WPWnet+nYurNGpV0rVYHI1YuOJwVHeM3t8f76m410XM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
`go test ./...` includes `TestAgentProviderConformanceMatrix`, a shared agent
|
||||
scenario that runs against every registered chat provider. The scenario asks an
|
||||
agent to call a deterministic local tool, verifies the tool receives `ai.RunInfo`,
|
||||
and checks the final response carries the conformance marker. A fake provider path
|
||||
runs on every machine without network access so CI always exercises the harness.
|
||||
and checks the final response carries the conformance marker. The live matrix
|
||||
includes MiniMax in the tool/guardrail path in addition to providers with
|
||||
streaming coverage, so every supported chat provider has at least one key-gated
|
||||
agent contract. A fake provider path runs on every machine without network
|
||||
access so CI always exercises the harness.
|
||||
|
||||
Live providers are opt-in to avoid flaky unauthenticated PR checks and accidental
|
||||
API spend. To run the live matrix, set `GO_MICRO_AGENT_CONFORMANCE_LIVE=1` plus the
|
||||
@@ -17,6 +20,7 @@ provider API keys you want to exercise:
|
||||
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
|
||||
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
|
||||
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
|
||||
| MiniMax | `MINIMAX_API_KEY` | `GO_MICRO_CONFORMANCE_MINIMAX_MODEL` |
|
||||
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
|
||||
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
|
||||
|
||||
@@ -43,7 +47,7 @@ contract.
|
||||
|
||||
## Scheduled CI
|
||||
|
||||
The daily/manual `Harness (E2E)` workflow runs the same matrix with
|
||||
The hourly/manual `Harness (E2E)` workflow runs the same matrix with
|
||||
`GO_MICRO_AGENT_CONFORMANCE_LIVE=1` and the provider secrets exported. Providers
|
||||
whose keys are absent still skip cleanly, while any configured provider must pass
|
||||
the shared tool-calling scenario. This keeps scheduled conformance key-gated: PR
|
||||
|
||||
@@ -21,8 +21,8 @@ Actions instead of subagents. Each role is a workflow:
|
||||
|------|------------------------|--------------|
|
||||
| **Planner** | `loop-planner.yml` — *Loop: Planner* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). Decides *what*. |
|
||||
| **Generator** | `loop-builder.yml` — *Loop: Builder (Generator)* | Builds the top open queue item as a single-concern PR (via Codex) and self-merges on green CI. Does the work. |
|
||||
| **Evaluator** | `harness.yml` — *Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
|
||||
| **Evaluator → feedback** | `loop-triage.yml` — *Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
|
||||
| **Evaluator** | `harness.yml` — *Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`, `govulncheck.yml`) | Grades every change: the mock harness + unit/lint + reachable-CVE scan on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
|
||||
| **Evaluator → feedback** | `loop-triage.yml` — *Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, govulncheck, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
|
||||
| **Coherence** | `loop-coherence.yml` — *Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
|
||||
| **Security** | `loop-security.yml` — *Loop: Security* | Weekly vulnerability audit of the attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps via `govulncheck`). Files `security` issues; **never auto-merges** fixes and **never publishes exploit detail** in public issues (responsible disclosure); risky fixes are `needs-human`. |
|
||||
| **Release** | `loop-release.yml` — *Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
|
||||
|
||||
@@ -49,19 +49,31 @@ type CreateResponse struct {
|
||||
}
|
||||
|
||||
type WorkspaceService struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
mu sync.Mutex
|
||||
n int
|
||||
byOwner map[string]*Workspace
|
||||
}
|
||||
|
||||
// Create provisions a workspace for a new user.
|
||||
// @example {"owner": "alice@acme.com"}
|
||||
func (s *WorkspaceService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
s.mu.Lock()
|
||||
if s.byOwner == nil {
|
||||
s.byOwner = make(map[string]*Workspace)
|
||||
}
|
||||
if ws, ok := s.byOwner[req.Owner]; ok {
|
||||
s.mu.Unlock()
|
||||
fmt.Printf(" \033[32m[workspace]\033[0m duplicate suppressed %s for %s\n", ws.ID, req.Owner)
|
||||
rsp.Workspace = ws
|
||||
return nil
|
||||
}
|
||||
s.n++
|
||||
id := fmt.Sprintf("ws-%d", s.n)
|
||||
ws := &Workspace{ID: id, Owner: req.Owner}
|
||||
s.byOwner[req.Owner] = ws
|
||||
s.mu.Unlock()
|
||||
fmt.Printf(" \033[32m[workspace]\033[0m created %s for %s\n", id, req.Owner)
|
||||
rsp.Workspace = &Workspace{ID: id, Owner: req.Owner}
|
||||
rsp.Workspace = ws
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,14 +91,26 @@ type SendResponse struct {
|
||||
Sent bool `json:"sent"`
|
||||
}
|
||||
type NotifyService struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
mu sync.Mutex
|
||||
n int
|
||||
sent map[string]bool
|
||||
}
|
||||
|
||||
// Send delivers a notification message to a recipient.
|
||||
// @example {"to": "alice@acme.com", "message": "Welcome"}
|
||||
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
|
||||
key := strings.ToLower(strings.TrimSpace(req.To))
|
||||
s.mu.Lock()
|
||||
if s.sent == nil {
|
||||
s.sent = make(map[string]bool)
|
||||
}
|
||||
if s.sent[key] {
|
||||
s.mu.Unlock()
|
||||
fmt.Printf(" \033[35m[notify]\033[0m duplicate suppressed to=%s message=%q\n", req.To, req.Message)
|
||||
rsp.Sent = true
|
||||
return nil
|
||||
}
|
||||
s.sent[key] = true
|
||||
s.n++
|
||||
s.mu.Unlock()
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
|
||||
@@ -178,6 +202,31 @@ func waitFor(reg registry.Registry, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
recovered := false
|
||||
for {
|
||||
workspaces, notifications := wsSvc.count(), ntSvc.count()
|
||||
if workspaces >= 1 && notifications >= 1 {
|
||||
return nil
|
||||
}
|
||||
if workspaces >= 1 && notifications == 0 && !recovered && recoverMissingNotify != nil {
|
||||
recovered = true
|
||||
if err := recoverMissingNotify(ctx); err != nil {
|
||||
return fmt.Errorf("agent-flow created workspace but failed to recover missing onboarding notification: workspaces=%d/1 notifications=%d/1: %w", workspaces, notifications, err)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("agent-flow missing required onboarding side effects before timeout: workspaces=%d/1 notifications=%d/1", workspaces, notifications)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
@@ -257,20 +306,25 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Wait for the agent to finish acting.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ntSvc.count() >= 1 && wsSvc.count() >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
// Wait for the agent to finish acting, and fail the harness if the
|
||||
// provider returns a successful reply without the required service side
|
||||
// effects. The 0→hero/provider conformance path must not print success
|
||||
// unless the services → agent → workflow contract actually happened.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, func(ctx context.Context) error {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m workspace exists before notify; retrying the welcome notification once before the flow can complete.\n")
|
||||
_, err := onboarder.Ask(ctx, "The workspace for alice@acme.com already exists. Send exactly one welcome notification to alice@acme.com now. Use the notify service. Do not create another workspace and do not answer until the notification tool call has succeeded.")
|
||||
return err
|
||||
})
|
||||
cancel()
|
||||
|
||||
fmt.Printf("\n\033[1mresult:\033[0m workspaces created=%d, notifications sent=%d\n", wsSvc.count(), ntSvc.count())
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Printf("flow reply: %s\n", rs[len(rs)-1].Reply)
|
||||
}
|
||||
if wsSvc.count() >= 1 && ntSvc.count() >= 1 {
|
||||
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
|
||||
if err != nil {
|
||||
fmt.Printf("\n\033[31m✗ %v\033[0m\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,3 +101,111 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
|
||||
t.Errorf("flow recorded no result for the event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForOnboardingSideEffectsFailsWhenMissing(t *testing.T) {
|
||||
wsSvc := new(WorkspaceService)
|
||||
ntSvc := new(NotifyService)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForOnboardingSideEffects returned nil, want missing side effects error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "workspaces=0/1") || !strings.Contains(got, "notifications=0/1") {
|
||||
t.Fatalf("waitForOnboardingSideEffects error %q does not report missing side effects", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForOnboardingSideEffectsPassesWhenComplete(t *testing.T) {
|
||||
wsSvc := new(WorkspaceService)
|
||||
ntSvc := new(NotifyService)
|
||||
|
||||
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := ntSvc.Send(context.Background(), &SendRequest{To: "alice@acme.com", Message: "Welcome"}, &SendResponse{}); err != nil {
|
||||
t.Fatalf("send notification: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil); err != nil {
|
||||
t.Fatalf("waitForOnboardingSideEffects returned %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForOnboardingSideEffectsRecoversMissingNotification(t *testing.T) {
|
||||
wsSvc := new(WorkspaceService)
|
||||
ntSvc := new(NotifyService)
|
||||
|
||||
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
|
||||
recovered := false
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
return ntSvc.Send(ctx, &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForOnboardingSideEffects returned %v, want recovered notification", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notification recovery did not run")
|
||||
}
|
||||
if got := ntSvc.count(); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1 after recovery", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCreateSuppressesDuplicateOwner(t *testing.T) {
|
||||
wsSvc := new(WorkspaceService)
|
||||
first := new(CreateResponse)
|
||||
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, first); err != nil {
|
||||
t.Fatalf("create first workspace: %v", err)
|
||||
}
|
||||
second := new(CreateResponse)
|
||||
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, second); err != nil {
|
||||
t.Fatalf("create duplicate workspace: %v", err)
|
||||
}
|
||||
|
||||
if got := wsSvc.count(); got != 1 {
|
||||
t.Fatalf("workspace creations = %d, want 1 after duplicate owner replay", got)
|
||||
}
|
||||
if first.Workspace == nil || second.Workspace == nil || second.Workspace.ID != first.Workspace.ID {
|
||||
t.Fatalf("duplicate create returned workspace %#v, want original %#v", second.Workspace, first.Workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySendSuppressesDuplicateMessage(t *testing.T) {
|
||||
ntSvc := new(NotifyService)
|
||||
req := &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}
|
||||
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
|
||||
t.Fatalf("send first notification: %v", err)
|
||||
}
|
||||
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
|
||||
t.Fatalf("send duplicate notification: %v", err)
|
||||
}
|
||||
|
||||
if got := ntSvc.count(); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1 after duplicate message replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySendSuppressesDuplicateRecipient(t *testing.T) {
|
||||
ntSvc := new(NotifyService)
|
||||
if err := ntSvc.Send(context.Background(), &SendRequest{To: "Alice@Acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{}); err != nil {
|
||||
t.Fatalf("send first notification: %v", err)
|
||||
}
|
||||
if err := ntSvc.Send(context.Background(), &SendRequest{To: " alice@acme.com ", Message: "Your workspace is ready."}, &SendResponse{}); err != nil {
|
||||
t.Fatalf("send duplicate recipient notification: %v", err)
|
||||
}
|
||||
|
||||
if got := ntSvc.count(); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1 after duplicate recipient replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,34 @@ require_output() {
|
||||
fi
|
||||
}
|
||||
|
||||
require_ordered_output() {
|
||||
local description=$1
|
||||
shift
|
||||
local -a expected=()
|
||||
while [[ $# -gt 0 && "$1" != "--" ]]; do
|
||||
expected+=("$1")
|
||||
shift
|
||||
done
|
||||
shift
|
||||
|
||||
local output
|
||||
if ! output=$("$MICRO" "$@" 2>&1); then
|
||||
echo "micro $* failed while checking $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local remainder=$output
|
||||
for text in "${expected[@]}"; do
|
||||
if [[ "$remainder" != *"$text"* ]]; then
|
||||
echo "micro $* missing expected ordered text '$text' for $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
remainder=${remainder#*"$text"}
|
||||
done
|
||||
}
|
||||
|
||||
require_output "version" "micro version" --version
|
||||
require_output "root help" "COMMANDS" --help
|
||||
require_output "service scaffold" "micro new" new --help
|
||||
@@ -58,4 +86,51 @@ require_output "agent chat" "micro chat" chat --help
|
||||
require_output "agent inspection" "micro inspect agent" inspect agent --help
|
||||
require_output "flow inspection" "micro inspect flow" inspect flow --help
|
||||
|
||||
require_ordered_output "installed first-agent docs wayfinding" \
|
||||
"micro agent demo" \
|
||||
"no-secret-first-agent.html" \
|
||||
"your-first-agent.html" \
|
||||
"micro agent preflight # before micro run: prerequisites" \
|
||||
"micro run" \
|
||||
"micro chat" \
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
|
||||
"debugging-agents.html" \
|
||||
"micro inspect agent <name>" \
|
||||
"zero-to-hero.html" \
|
||||
-- docs
|
||||
|
||||
require_ordered_output "installed provider-free examples wayfinding" \
|
||||
"go run ./examples/first-agent" \
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
|
||||
"go run ./examples/support" \
|
||||
"micro agent demo" \
|
||||
"micro docs" \
|
||||
"micro zero-to-hero" \
|
||||
"no-secret-first-agent.html" \
|
||||
"your-first-agent.html" \
|
||||
"debugging-agents.html" \
|
||||
"zero-to-hero.html" \
|
||||
-- examples
|
||||
|
||||
require_ordered_output "installed no-secret agent demo" \
|
||||
"provider-free" \
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
|
||||
"your-first-agent.html" \
|
||||
"debugging-agents.html" \
|
||||
"zero-to-hero.html" \
|
||||
"micro agent preflight # before micro run: prerequisites" \
|
||||
"micro run" \
|
||||
"micro chat" \
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
|
||||
"micro inspect agent <name>" \
|
||||
-- agent demo
|
||||
|
||||
require_ordered_output "installed zero-to-hero lifecycle wayfinding" \
|
||||
"./internal/harness/zero-to-hero-ci/run.sh" \
|
||||
"go run ./examples/first-agent" \
|
||||
"go run ./examples/support" \
|
||||
"make harness" \
|
||||
"zero-to-hero.html" \
|
||||
-- zero-to-hero
|
||||
|
||||
echo "✓ install smoke path verified"
|
||||
|
||||
@@ -157,7 +157,7 @@ func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendRes
|
||||
if s.bySend == nil {
|
||||
s.bySend = map[string]bool{}
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(req.To)) + "\x00" + strings.ToLower(strings.TrimSpace(req.Message))
|
||||
key := notifyDedupKey(req.To, req.Message)
|
||||
s.attempts++
|
||||
if !s.bySend[key] {
|
||||
s.bySend[key] = true
|
||||
@@ -184,6 +184,66 @@ func (s *NotifyService) duplicateAttempts() int {
|
||||
return s.duplicates
|
||||
}
|
||||
|
||||
func notifyDedupKey(to, message string) string {
|
||||
recipient := canonicalLaunchNotifyRecipient(normalizeNotifyText(to))
|
||||
body := normalizeNotifyText(message)
|
||||
if recipient == "owner@acme.com" && isLaunchReadinessNotify(body) {
|
||||
body = "launch-readiness"
|
||||
}
|
||||
return recipient + "\x00" + body
|
||||
}
|
||||
|
||||
func canonicalLaunchNotifyRecipient(recipient string) string {
|
||||
recipient = canonicalSpokenEmailRecipient(recipient)
|
||||
switch recipient {
|
||||
case "owner", "launch owner", "plan owner", "owner acme com", "owner@acme com", "owner @ acme com":
|
||||
return "owner@acme.com"
|
||||
default:
|
||||
if strings.Contains(recipient, "owner") && strings.Contains(recipient, "acme") {
|
||||
return "owner@acme.com"
|
||||
}
|
||||
return recipient
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSpokenEmailRecipient(recipient string) string {
|
||||
fields := strings.Fields(recipient)
|
||||
if len(fields) == 5 && fields[1] == "at" && fields[3] == "dot" {
|
||||
return fields[0] + "@" + fields[2] + "." + fields[4]
|
||||
}
|
||||
return recipient
|
||||
}
|
||||
|
||||
func normalizeNotifyText(message string) string {
|
||||
message = strings.ToLower(strings.TrimSpace(message))
|
||||
message = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '@':
|
||||
return r
|
||||
default:
|
||||
return ' '
|
||||
}
|
||||
}, message)
|
||||
return strings.Join(strings.Fields(message), " ")
|
||||
}
|
||||
|
||||
func isLaunchReadinessNotify(message string) bool {
|
||||
hasLaunch := strings.Contains(message, "launch")
|
||||
hasPlanOrReadiness := strings.Contains(message, "plan") ||
|
||||
strings.Contains(message, "readiness") ||
|
||||
strings.Contains(message, "ready")
|
||||
hasCompletion := strings.Contains(message, "ready") ||
|
||||
strings.Contains(message, "readiness") ||
|
||||
strings.Contains(message, "prepared") ||
|
||||
strings.Contains(message, "complete") ||
|
||||
strings.Contains(message, "finished") ||
|
||||
strings.Contains(message, "done") ||
|
||||
strings.Contains(message, "sent")
|
||||
return hasLaunch && hasPlanOrReadiness && hasCompletion
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock LLM provider — the ONLY fake. It "reasons" by simple heuristics
|
||||
// over the tools it's offered and the system prompt it's given, calling
|
||||
@@ -199,6 +259,15 @@ type mockModel struct {
|
||||
// still keeping the regression deterministic and keyless.
|
||||
unknownDelegateOnce bool
|
||||
emittedUnknownDelegate bool
|
||||
|
||||
// duplicateNotify makes the comms mock replay the same notification call.
|
||||
// The notify service should collapse that replay to one durable side effect.
|
||||
duplicateNotify bool
|
||||
|
||||
// duplicateDelegate makes the conductor mock replay the same delegate call.
|
||||
// The delegate idempotency path should collapse that replay before it can
|
||||
// ask the delegated comms agent to notify twice.
|
||||
duplicateDelegate bool
|
||||
}
|
||||
|
||||
func newMock(opts ...ai.Option) ai.Model {
|
||||
@@ -213,6 +282,18 @@ func newMockUnknownDelegate(opts ...ai.Option) ai.Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func newMockDuplicateNotify(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{duplicateNotify: true}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func newMockDuplicateDelegate(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{duplicateDelegate: true}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
@@ -254,10 +335,14 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
// comms agent: owns notify, has Send but not Add.
|
||||
case hasSend && !hasAdd:
|
||||
send := findTool(req.Tools, "Send")
|
||||
m.call("comms", send, map[string]any{
|
||||
input := map[string]any{
|
||||
"to": "owner@acme.com",
|
||||
"message": "The launch plan is ready",
|
||||
})
|
||||
}
|
||||
m.call("comms", send, input)
|
||||
if m.duplicateNotify {
|
||||
m.call("comms", send, input)
|
||||
}
|
||||
return &ai.Response{Answer: "Notified owner@acme.com."}, nil
|
||||
|
||||
// conductor: has the task Add tool — plan, create tasks, delegate.
|
||||
@@ -285,10 +370,14 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
"to": "comms",
|
||||
})
|
||||
} else {
|
||||
m.call("conductor", del, map[string]any{
|
||||
input := map[string]any{
|
||||
"task": delegatedNotifyTask,
|
||||
"to": "comms",
|
||||
})
|
||||
}
|
||||
m.call("conductor", del, input)
|
||||
if m.duplicateDelegate {
|
||||
m.call("conductor", del, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &ai.Response{Answer: "Created Design, Build and Ship, and had comms notify the owner."}, nil
|
||||
@@ -322,6 +411,10 @@ func runPlanDelegate(provider string) error {
|
||||
ai.Register("mock", newMock)
|
||||
case "mock-unknown-delegate":
|
||||
ai.Register("mock-unknown-delegate", newMockUnknownDelegate)
|
||||
case "mock-duplicate-notify":
|
||||
ai.Register("mock-duplicate-notify", newMockDuplicateNotify)
|
||||
case "mock-duplicate-delegate":
|
||||
ai.Register("mock-duplicate-delegate", newMockDuplicateDelegate)
|
||||
default:
|
||||
apiKey = providerKey(provider)
|
||||
if apiKey == "" {
|
||||
@@ -346,14 +439,20 @@ func runPlanDelegate(provider string) error {
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
return fmt.Errorf("task handle: %w", err)
|
||||
}
|
||||
go task.Run()
|
||||
if err := task.Start(); err != nil {
|
||||
return fmt.Errorf("task start: %w", err)
|
||||
}
|
||||
defer task.Stop()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
return fmt.Errorf("notify handle: %w", err)
|
||||
}
|
||||
go notify.Run()
|
||||
if err := notify.Start(); err != nil {
|
||||
return fmt.Errorf("notify start: %w", err)
|
||||
}
|
||||
defer notify.Stop()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
commsOpts := []agent.Option{
|
||||
@@ -403,8 +502,14 @@ func runPlanDelegate(provider string) error {
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
flow.Steps(
|
||||
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor, taskSvc, notifySvc)},
|
||||
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := comms.Ask(ctx, "Send exactly one owner readiness notification now with this exact task: "+delegatedNotifyTask+" Use the notify service and do not answer until the notification has been sent.")
|
||||
return err
|
||||
})},
|
||||
),
|
||||
flow.WithCheckpoint(flow.StoreCheckpoint(mem, "flow-zero-to-hero")),
|
||||
flow.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
@@ -420,23 +525,19 @@ func runPlanDelegate(provider string) error {
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
_, err := comms.Ask(ctx, "Recover the missing owner readiness notification now for the launch work already created. Send exactly one notification with this exact task: "+delegatedNotifyTask+" Use the notify service and do not create or modify tasks.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rs[len(rs)-1].Reply)
|
||||
}
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
if recs, _ := store.Scope(mem, "agent", "conductor").Read("plan"); len(recs) > 0 {
|
||||
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
|
||||
} else {
|
||||
return fmt.Errorf("plan was not persisted")
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
if taskSvc.count() == 0 || notifySvc.count() != 1 {
|
||||
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
|
||||
}
|
||||
|
||||
@@ -444,6 +545,82 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, notifySvc *NotifyService) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
|
||||
rsp, err := conductor.Ask(ctx, prompt)
|
||||
if err != nil {
|
||||
if isUnfinishedPlanError(err) && taskSvc != nil && notifySvc != nil && taskSvc.count() > 0 && notifySvc.count() == 0 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m conductor stopped with unfinished delegation after creating tasks; continuing to require-notify recovery: %v\n", err)
|
||||
return in, nil
|
||||
}
|
||||
return in, err
|
||||
}
|
||||
if rsp != nil && rsp.Reply != "" {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
|
||||
}
|
||||
if taskSvc != nil && notifySvc != nil && taskSvc.count() == 0 && notifySvc.count() == 0 {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m conductor persisted/planned without service side effects; retrying task execution before notify gate.\n")
|
||||
rsp, err = conductor.Ask(ctx, "Continue the launch-readiness run now. Execute the persisted plan by calling the task Add tool exactly once for Design, Build, and Ship, then delegate the owner readiness notification to comms. Do not answer until at least one required tool call succeeds.")
|
||||
if err != nil {
|
||||
if isUnfinishedPlanError(err) && taskSvc.count() > 0 && notifySvc.count() == 0 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m conductor recovered tasks but stopped before notification; continuing to require-notify recovery: %v\n", err)
|
||||
return in, nil
|
||||
}
|
||||
return in, err
|
||||
}
|
||||
if rsp != nil && rsp.Reply != "" {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
|
||||
}
|
||||
}
|
||||
if taskSvc != nil && notifySvc != nil && taskSvc.count() == 0 {
|
||||
return in, fmt.Errorf("plan-delegate reached notify gate before task side effects completed (tasks=0/3 notify=%d/1); model produced a plan but did not call task Add for Design, Build, and Ship", notifySvc.count())
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func isUnfinishedPlanError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unfinished plan steps")
|
||||
}
|
||||
|
||||
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if notify == 1 {
|
||||
return in, nil
|
||||
}
|
||||
if recoverMissingNotify == nil || tasks == 0 || notify != 0 {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m conductor step completed before delegated notify; retrying the missing comms handoff once before the flow can complete.\n")
|
||||
if err := recoverMissingNotify(ctx); err != nil {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, err)
|
||||
}
|
||||
settled, err = waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if !settled {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notifySvc.count())
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
@@ -453,40 +630,36 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if err != nil {
|
||||
if hasCompletedPlanDelegateSideEffects(tasks, notify) {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
if isClientTimeout(err) {
|
||||
if tasks == 3 && notify == 1 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
|
||||
return classifiedPlanDelegateTimeout(tasks, notify, err)
|
||||
}
|
||||
if isUnfinishedPlanError(err) && tasks > 0 && notify == 0 && recoverMissingNotify != nil {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m flow stopped after partial plan side effects; recovering missing delegated notify: %v\n", err)
|
||||
if recoverErr := recoverMissingNotify(context.Background()); recoverErr != nil {
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d and recovery failed: %w", tasks, notify, recoverErr)
|
||||
}
|
||||
settled, waitErr := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
if settled {
|
||||
return nil
|
||||
}
|
||||
return classifiedPlanDelegateTimeout(tasks, notify, err)
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: delegation recovery completed without required notify side effect: notify=%d, want 1", tasks, notify, notifySvc.count())
|
||||
}
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
|
||||
}
|
||||
if notify != 1 {
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
retryErr := recoverMissingNotify(ctx)
|
||||
cancel()
|
||||
if retryErr != nil {
|
||||
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if dup := notifySvc.duplicateAttempts(); dup > 0 {
|
||||
return fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
|
||||
if notifySvc.count() == 1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,9 +671,6 @@ func waitForNotifySideEffect(notifySvc *NotifyService, timeout time.Duration) (b
|
||||
if notifySvc.count() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
if dup := notifySvc.duplicateAttempts(); dup > 0 {
|
||||
return false, fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -508,6 +678,10 @@ func waitForNotifySideEffect(notifySvc *NotifyService, timeout time.Duration) (b
|
||||
}
|
||||
}
|
||||
|
||||
func hasCompletedPlanDelegateSideEffects(tasks, notify int) bool {
|
||||
return tasks == 3 && notify == 1
|
||||
}
|
||||
|
||||
func classifiedPlanDelegateTimeout(tasks, notify int, err error) error {
|
||||
return fmt.Errorf("provider latency/outage during plan-delegate before required side effects completed (tasks=%d/3 notify=%d/1); retry live provider or inspect provider logs if this recurs: %w", tasks, notify, err)
|
||||
}
|
||||
@@ -518,7 +692,7 @@ func isClientTimeout(err error) bool {
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, mock-duplicate-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
if err := runPlanDelegate(*provider); err != nil {
|
||||
|
||||
@@ -53,14 +53,20 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
if err := task.Start(); err != nil {
|
||||
t.Fatalf("start task: %v", err)
|
||||
}
|
||||
defer task.Stop()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
go notify.Run()
|
||||
if err := notify.Start(); err != nil {
|
||||
t.Fatalf("start notify: %v", err)
|
||||
}
|
||||
defer notify.Stop()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
@@ -136,14 +142,20 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
if err := task.Start(); err != nil {
|
||||
t.Fatalf("start task: %v", err)
|
||||
}
|
||||
defer task.Stop()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
go notify.Run()
|
||||
if err := notify.Start(); err != nil {
|
||||
t.Fatalf("start notify: %v", err)
|
||||
}
|
||||
defer notify.Stop()
|
||||
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
@@ -231,6 +243,48 @@ func TestPlanDelegateRetriesAfterUnknownDelegateTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateIdempotentDuplicateNotifyReplay(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock-duplicate-notify"); err != nil {
|
||||
t.Fatalf("0→hero harness with duplicate notify replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateIdempotentDuplicateDelegateReplay(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock-duplicate-delegate"); err != nil {
|
||||
t.Fatalf("0→hero harness with duplicate delegate replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyServiceDeduplicatesAtlasCloudLaunchReadinessParaphrases(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
variants := []SendRequest{
|
||||
{To: "owner at acme dot com", Message: "The launch plan is ready."},
|
||||
{To: "launch owner", Message: "Launch readiness is complete."},
|
||||
{To: "Owner <owner@acme.com>", Message: "The launch plan is finished and the readiness notification was sent."},
|
||||
}
|
||||
for _, req := range variants {
|
||||
var rsp SendResponse
|
||||
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
|
||||
t.Fatalf("Send(%+v): %v", req, err)
|
||||
}
|
||||
if !rsp.Sent {
|
||||
t.Fatalf("Send(%+v) returned sent=false", req)
|
||||
}
|
||||
}
|
||||
if got := svc.count(); got != 1 {
|
||||
t.Fatalf("notify side effects = %d, want 1 for launch-readiness paraphrase replays", got)
|
||||
}
|
||||
if got := svc.duplicateAttempts(); got != len(variants)-1 {
|
||||
t.Fatalf("duplicate attempts = %d, want %d", got, len(variants)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
|
||||
svc := new(TaskService)
|
||||
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
|
||||
@@ -247,7 +301,7 @@ func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T) {
|
||||
func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
|
||||
notifySvc := new(NotifyService)
|
||||
for i := 0; i < 2; i++ {
|
||||
var rsp SendResponse
|
||||
@@ -256,20 +310,46 @@ func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want duplicate replay accepted", err)
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after duplicate replay", got)
|
||||
}
|
||||
if got := notifySvc.duplicateAttempts(); got != 1 {
|
||||
t.Fatalf("duplicate attempts = %d, want 1 recorded replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want duplicate notify error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "duplicate notify attempts") {
|
||||
t.Fatalf("error = %q, want duplicate notify attempts", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitForPlanDelegateExecution did not report duplicate notify before timeout")
|
||||
func TestPlanDelegateExecutionRecoversUnfinishedPlanAfterPartialTaskSideEffect(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
var addRsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: "Design"}, &addRsp); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")
|
||||
|
||||
recovered := false
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var sendRsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &sendRsp)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want partial notify recovery", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notify recovery did not run")
|
||||
}
|
||||
if got := taskSvc.count(); got != 1 {
|
||||
t.Fatalf("task count = %d, want completed partial task to stay singular", got)
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want recovered notify side effect", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +367,120 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedAgent struct {
|
||||
replies []func(context.Context, string) (*agent.Response, error)
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *scriptedAgent) Name() string { return "scripted" }
|
||||
func (a *scriptedAgent) Init(...agent.Option) {}
|
||||
func (a *scriptedAgent) Options() agent.Options { return agent.Options{} }
|
||||
func (a *scriptedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
|
||||
func (a *scriptedAgent) Run() error { return nil }
|
||||
func (a *scriptedAgent) Stop() error { return nil }
|
||||
func (a *scriptedAgent) String() string { return "scripted" }
|
||||
func (a *scriptedAgent) Ask(ctx context.Context, prompt string) (*agent.Response, error) {
|
||||
if a.calls >= len(a.replies) {
|
||||
return &agent.Response{Reply: "done"}, nil
|
||||
}
|
||||
reply := a.replies[a.calls]
|
||||
a.calls++
|
||||
return reply(ctx, prompt)
|
||||
}
|
||||
|
||||
type failingAgent struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (a failingAgent) Name() string { return "failing" }
|
||||
func (a failingAgent) Init(...agent.Option) {}
|
||||
func (a failingAgent) Options() agent.Options { return agent.Options{} }
|
||||
func (a failingAgent) Ask(context.Context, string) (*agent.Response, error) { return nil, a.err }
|
||||
func (a failingAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, a.err }
|
||||
func (a failingAgent) Run() error { return nil }
|
||||
func (a failingAgent) Stop() error { return nil }
|
||||
func (a failingAgent) String() string { return "failing" }
|
||||
|
||||
func TestPlanDelegateConductorRetriesAfterPlanOnlySuccess(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
notifySvc := new(NotifyService)
|
||||
ag := &scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
|
||||
func(context.Context, string) (*agent.Response, error) {
|
||||
return &agent.Response{Reply: "Plan saved."}, nil
|
||||
},
|
||||
func(ctx context.Context, prompt string) (*agent.Response, error) {
|
||||
if !strings.Contains(prompt, "Execute the persisted plan") {
|
||||
return nil, errors.New("missing explicit side-effect recovery prompt")
|
||||
}
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(ctx, &AddRequest{Title: title}, &rsp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &agent.Response{Reply: "Tasks created."}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
step := planDelegateConductorStep(ag, taskSvc, notifySvc)
|
||||
if _, err := step(context.Background(), flow.State{}); err != nil {
|
||||
t.Fatalf("planDelegateConductorStep returned %v, want plan-only retry success", err)
|
||||
}
|
||||
if ag.calls != 2 {
|
||||
t.Fatalf("conductor calls = %d, want initial plan-only call plus one side-effect retry", ag.calls)
|
||||
}
|
||||
if got := taskSvc.count(); got != 3 {
|
||||
t.Fatalf("task count = %d, want recovered Design/Build/Ship side effects", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateConductorFailsBeforeNotifyGateAfterPlanOnlyRetryMiss(t *testing.T) {
|
||||
step := planDelegateConductorStep(&scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
|
||||
func(context.Context, string) (*agent.Response, error) {
|
||||
return &agent.Response{Reply: "Plan saved."}, nil
|
||||
},
|
||||
func(context.Context, string) (*agent.Response, error) {
|
||||
return &agent.Response{Reply: "Still planning."}, nil
|
||||
},
|
||||
}}, new(TaskService), new(NotifyService))
|
||||
|
||||
_, err := step(context.Background(), flow.State{})
|
||||
if err == nil {
|
||||
t.Fatal("planDelegateConductorStep returned nil, want pre-notify-gate task side-effect error")
|
||||
}
|
||||
for _, want := range []string{"before task side effects completed", "tasks=0/3", "task Add for Design, Build, and Ship"} {
|
||||
if got := err.Error(); !strings.Contains(got, want) {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateConductorAllowsNotifyRecoveryAfterUnfinishedDelegation(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")}, taskSvc, notifySvc)
|
||||
if _, err := step(context.Background(), flow.State{}); err != nil {
|
||||
t.Fatalf("planDelegateConductorStep returned %v, want require-notify recovery to run", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateConductorKeepsUnfinishedTaskFailureActionable(t *testing.T) {
|
||||
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Create Build task")}, new(TaskService), new(NotifyService))
|
||||
err := func() error { _, err := step(context.Background(), flow.State{}); return err }()
|
||||
if err == nil {
|
||||
t.Fatal("planDelegateConductorStep returned nil, want unfinished task error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "Create Build task") {
|
||||
t.Fatalf("error = %q, want original unfinished task detail", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
@@ -296,15 +490,13 @@ func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
recovered := false
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})
|
||||
})(context.Background(), flow.State{})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
|
||||
}
|
||||
@@ -325,8 +517,6 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
@@ -335,11 +525,11 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}()
|
||||
|
||||
recovered := false
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})
|
||||
})(context.Background(), flow.State{})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want in-flight notify success", err)
|
||||
}
|
||||
@@ -357,6 +547,37 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateRecoveryWaitsForRecoveredNotifySideEffect(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var rsp SendResponse
|
||||
_ = notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
}()
|
||||
return nil
|
||||
})(context.Background(), flow.State{})
|
||||
if err != nil {
|
||||
t.Fatalf("requireDelegatedNotifyStep returned %v, want delayed recovery success", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notify recovery did not run")
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want recovered notify side effect", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
@@ -379,6 +600,28 @@ func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsApprovalPauseAfterSideEffects(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
var rsp SendResponse
|
||||
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New("agent run abc paused for approval: The comms agent is repeatedly timing out (408 errors) while retrying the launch-readiness notification")
|
||||
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy approval pause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionClassifiesClientTimeoutBeforeSideEffects(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
@@ -421,9 +664,18 @@ func TestPlanDelegateExecutionClassifiesPartialClientTimeout(t *testing.T) {
|
||||
|
||||
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
for i := 0; i < 3; i++ {
|
||||
messages := []string{
|
||||
"The launch plan is ready",
|
||||
"The launch plan is ready.",
|
||||
"Launch readiness: the plan is ready!",
|
||||
}
|
||||
for i, message := range messages {
|
||||
var rsp SendResponse
|
||||
if err := svc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
|
||||
to := "owner@acme.com"
|
||||
if i == len(messages)-1 {
|
||||
to = "owner"
|
||||
}
|
||||
if err := svc.Send(context.Background(), &SendRequest{To: to, Message: message}, &rsp); err != nil {
|
||||
t.Fatalf("Send attempt %d: %v", i+1, err)
|
||||
}
|
||||
if !rsp.Sent {
|
||||
@@ -433,4 +685,33 @@ func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
if got := svc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after duplicate delivery replays", got)
|
||||
}
|
||||
if got := svc.duplicateAttempts(); got != len(messages)-1 {
|
||||
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(messages)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyServiceCollapsesProviderReadinessParaphrases(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
requests := []SendRequest{
|
||||
{To: "owner@acme.com", Message: "The launch plan is ready"},
|
||||
{To: "owner @ acme.com", Message: "Launch plan ready."},
|
||||
{To: "owner at acme dot com", Message: "The launch plan is ready."},
|
||||
{To: "launch owner", Message: "The launch readiness plan is prepared."},
|
||||
{To: "plan owner", Message: "Launch plan is complete!"},
|
||||
}
|
||||
for i, req := range requests {
|
||||
var rsp SendResponse
|
||||
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
|
||||
t.Fatalf("Send attempt %d: %v", i+1, err)
|
||||
}
|
||||
if !rsp.Sent {
|
||||
t.Fatalf("Send attempt %d reported Sent=false", i+1)
|
||||
}
|
||||
}
|
||||
if got := svc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after provider paraphrase replays", got)
|
||||
}
|
||||
if got := svc.duplicateAttempts(); got != len(requests)-1 {
|
||||
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(requests)-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ go run ./internal/harness/provider-conformance \
|
||||
## Scheduled CI behavior
|
||||
|
||||
The `Harness (E2E)` workflow runs on pushes and pull requests with deterministic
|
||||
mock LLMs, including `provider-conformance -providers mock`. On the daily
|
||||
mock LLMs, including `provider-conformance -providers mock`. On the hourly
|
||||
schedule and manual dispatch it also runs the live provider conformance job. A
|
||||
manual dispatch can narrow `providers` or `harnesses`, and can set
|
||||
`require_configured=true` to fail fast when an expected repository secret is
|
||||
|
||||
@@ -206,10 +206,10 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
|
||||
|
||||
func capabilityMarkdown(rows []ai.CapabilityRow) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("| Provider | Model | Image | Video | Streaming |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
b.WriteString("| Provider | Model | Image | Video | Streaming | Tool streaming |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream))
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream), mark(row.ToolStream))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -250,9 +250,9 @@ func mark(ok bool) string {
|
||||
|
||||
func printCapabilityMatrix() {
|
||||
fmt.Println("Provider capability matrix:")
|
||||
fmt.Println("provider model image video stream")
|
||||
fmt.Println("provider model image video stream tool-stream")
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
fmt.Printf("%-12s %-5s %-5s %-5s %-6s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
|
||||
fmt.Printf("%-12s %-5s %-5s %-5s %-6s %-11s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream), yesNo(row.ToolStream))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -421,7 +421,7 @@ func runAgentConformance(provider string, timeout time.Duration) error {
|
||||
if provider == "mock" {
|
||||
testProvider = "fake"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProviderConformanceMatrix", "-count=1", "-v")
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProvider(ConformanceMatrix|StreamConformanceMatrix)", "-count=1", "-v")
|
||||
cmd.Dir = repoRoot()
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestWriteCapabilityMarkdown(t *testing.T) {
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
"| Provider | Model | Image | Video | Streaming |",
|
||||
"| Provider | Model | Image | Video | Streaming | Tool streaming |",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| vision | — | ✅ | ✅ | — |",
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHarnessWorkflowSchedulesLiveProviderMatrix(t *testing.T) {
|
||||
path := filepath.Join(repoRoot(), ".github", "workflows", "harness.yml")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read harness workflow: %v", err)
|
||||
}
|
||||
workflow := string(b)
|
||||
|
||||
checks := []string{
|
||||
`name: Harness (E2E)`,
|
||||
`schedule:`,
|
||||
`cron: "17 * * * *"`,
|
||||
`workflow_dispatch:`,
|
||||
`harness-live:`,
|
||||
`if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'`,
|
||||
`ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}`,
|
||||
`OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}`,
|
||||
`GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}`,
|
||||
`GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}`,
|
||||
`MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}`,
|
||||
`MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}`,
|
||||
`TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}`,
|
||||
`ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}`,
|
||||
`-summary-json provider-conformance-summary.json`,
|
||||
`-summary-markdown provider-conformance-summary.md`,
|
||||
`-capabilities-markdown provider-capabilities.md`,
|
||||
`actions/upload-artifact@v4`,
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Fatalf("harness workflow missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,13 +269,21 @@ func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf
|
||||
in.Data = []byte("Buyer notified.")
|
||||
return in, nil
|
||||
}
|
||||
wait := time.NewTimer(25 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if dispatchErr != nil {
|
||||
return in, dispatchErr
|
||||
if !wait.Stop() {
|
||||
<-wait.C
|
||||
}
|
||||
return in, ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
if dispatchErr == nil {
|
||||
return in, ctx.Err()
|
||||
}
|
||||
// A timed-out Agent.Chat call can report the caller context as done
|
||||
// while the remote agent is still finishing its notify tool call.
|
||||
// Keep watching for the idempotent side effect until the local settle
|
||||
// window expires so a post-side-effect timeout does not strand the
|
||||
// durable checkout run as pending.
|
||||
case <-wait.C:
|
||||
}
|
||||
}
|
||||
if dispatchErr != nil {
|
||||
@@ -414,7 +422,7 @@ func runUniverse(provider string) int {
|
||||
// Services.
|
||||
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
|
||||
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Broker(br), service.Client(cl))
|
||||
svc.Handle(h)
|
||||
go svc.Run()
|
||||
}
|
||||
@@ -429,6 +437,7 @@ func runUniverse(provider string) int {
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.MaxSteps(5),
|
||||
agent.WithBroker(br),
|
||||
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
atomic.AddInt64(&wrapped, 1)
|
||||
|
||||
@@ -69,6 +69,42 @@ func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepWaitsForObservedSideEffectAfterCanceledDispatchContext(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "buyer@acme.com",
|
||||
Message: "Your order is confirmed.",
|
||||
}, &rsp); err != nil {
|
||||
t.Errorf("send notification: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := completeNotifyOnObservedSideEffect(
|
||||
ctx,
|
||||
flow.State{Data: []byte(`{"order":"order-1"}`)},
|
||||
ntf,
|
||||
before,
|
||||
time.Second,
|
||||
errors.New("client observed timeout"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("notify completion returned error: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "Buyer notified." {
|
||||
t.Fatalf("result = %q, want Buyer notified.", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepRejectsClaimedCompletionWithoutSideEffect(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
|
||||
@@ -4,17 +4,21 @@ This directory owns the no-secret reference scenario for the Go Micro
|
||||
services → agents → workflows lifecycle. It is intentionally small and
|
||||
scripted so CI can run it on every push without external services or model keys.
|
||||
|
||||
`run.sh` verifies five boundaries together:
|
||||
`run.sh` verifies the complete first-agent 0→hero contract together:
|
||||
|
||||
1. **First agent** — `micro new`, `micro agent preflight`, `micro run`,
|
||||
`micro chat`, and `micro inspect agent <name>` remain available as the
|
||||
documented first-agent walkthrough path.
|
||||
2. **Run** — `micro run` remains available as the local development entry point.
|
||||
3. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
4. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
remain available as the local run-history inspection step, with `micro flow
|
||||
runs` preserving durable workflow history inspection.
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
1. **Scaffold** — the maintained `micro new` 0→1 contract still creates
|
||||
runnable services from a clean workspace.
|
||||
2. **First agent** — `micro agent demo`, `micro examples`, `micro agent preflight`,
|
||||
`micro run`, `micro chat`, and `micro inspect agent <name>` remain available
|
||||
as the documented first-agent walkthrough path.
|
||||
3. **Run** — `micro run` remains available as the local development entry point.
|
||||
4. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
5. **Inspect/debugging** — `micro inspect agent <name>`, `micro agent history <name>`,
|
||||
and `micro inspect flow <name>` remain available as the local run-history
|
||||
inspection step. The no-secret debugging smoke seeds durable agent run history
|
||||
and memory, then runs the documented inspect/history commands without provider
|
||||
credentials; `micro flow runs` preserves durable workflow history inspection.
|
||||
6. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
building binaries, opening SSH connections, running `rsync`, or touching
|
||||
@@ -28,16 +32,23 @@ and A2A with only the LLM mocked.
|
||||
|
||||
The default GitHub harness workflow runs this script on every push and pull
|
||||
request after the install smoke check and 0→1 scaffold contract. Developers can
|
||||
verify the installer seam alone with `make install-smoke`, or run the same
|
||||
no-secret contract locally with:
|
||||
verify the first-agent on-ramp links and CLI command-output parity with
|
||||
`make docs-wayfinding` whenever README or website first-agent breadcrumbs,
|
||||
`micro agent demo`, `micro examples`, or `micro zero-to-hero` change. That
|
||||
check is provider-free and fails if documented command names, guide links, or
|
||||
maintained no-secret example paths drift from the CLI outputs. To verify the
|
||||
installed first-run CLI seam alone, use `make install-smoke`; to run just the documented
|
||||
agent debugging quickcheck with
|
||||
`go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`,
|
||||
or run the same no-secret contract locally with:
|
||||
|
||||
```sh
|
||||
make harness
|
||||
```
|
||||
|
||||
That target intentionally exercises the install script smoke path, both 0→1
|
||||
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
|
||||
mock provider conformance, so
|
||||
That target intentionally exercises the first-agent docs wayfinding guard, the
|
||||
install script smoke path, both 0→1 scaffold variants, the 0→hero scenario, the
|
||||
event-driven agent-flow harness, and mock provider conformance, so
|
||||
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
|
||||
outside CI as well. Live provider checks remain separate and gated by configured
|
||||
API keys (`make provider-conformance` or the scheduled/manual CI job).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,36 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
run_step() {
|
||||
local name=$1
|
||||
shift
|
||||
|
||||
printf '\n==> %s\n' "$name"
|
||||
printf '+ %q' "$@"
|
||||
printf '\n'
|
||||
"$@"
|
||||
}
|
||||
|
||||
# Keep the developer inner-loop boundaries executable and discoverable in CI
|
||||
# without secrets or long-running daemons.
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestZeroToHeroReferenceDocs' -count=1
|
||||
# without secrets or long-running daemons. Step names mirror the documented
|
||||
# install → scaffold → run/chat → inspect → deploy-dry-run seams so failures
|
||||
# identify the broken part of the getting-started contract.
|
||||
run_step "scaffold: 0→1 service contract" \
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
run_step "run/chat/inspect: first-agent CLI boundaries" \
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
|
||||
run_step "deploy dry-run: configured target plan" \
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
run_step "chat/inspect: no-secret first-agent transcript and docs" \
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -count=1
|
||||
|
||||
# Deterministic no-secret reference scenarios. These use the real Go Micro
|
||||
# runtime and mock only the LLM provider. The support example is the maintained
|
||||
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
|
||||
# journey cannot drift from the framework.
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
|
||||
run_step "first-agent app: runnable provider-free example" \
|
||||
go test ./examples/first-agent -run TestRunFirstAgent -count=1
|
||||
run_step "0→hero app: support lifecycle smoke" \
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
run_step "flow history: deterministic services → agents → workflows harnesses" \
|
||||
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
|
||||
|
||||
@@ -3,6 +3,8 @@ core:
|
||||
url: /docs/
|
||||
- title: Getting Started
|
||||
url: /docs/getting-started.html
|
||||
- title: Install Troubleshooting
|
||||
url: /docs/guides/install-troubleshooting.html
|
||||
- title: AI Integration
|
||||
url: /docs/ai-integration.html
|
||||
- title: No-secret First Agent
|
||||
@@ -40,6 +42,8 @@ examples:
|
||||
guides:
|
||||
- title: Debugging your agent
|
||||
url: /docs/guides/debugging-agents.html
|
||||
- title: micro loop quickstart
|
||||
url: /docs/guides/micro-loop.html
|
||||
- title: Plan & Delegate
|
||||
url: /docs/guides/plan-delegate.html
|
||||
- title: Agent Guardrails
|
||||
@@ -80,9 +84,11 @@ project:
|
||||
- title: Server (optional)
|
||||
url: /docs/server.html
|
||||
search_order:
|
||||
- /docs/guides/install-troubleshooting.html
|
||||
- /docs/guides/your-first-agent.html
|
||||
- /docs/guides/zero-to-hero.html
|
||||
- /docs/guides/debugging-agents.html
|
||||
- /docs/guides/micro-loop.html
|
||||
- /docs/getting-started.html
|
||||
- /docs/mcp.html
|
||||
- /docs/architecture.html
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
layout: blog
|
||||
title: "What's New in Go Micro: v6.3.15"
|
||||
permalink: /blog/34
|
||||
description: "Go Micro v6.3.15 tightens the first-agent on-ramp, adds Anthropic streaming, and hardens plan/delegate plus text tool-call recovery."
|
||||
---
|
||||
|
||||
# What's New in Go Micro: v6.3.15
|
||||
|
||||
*July 5, 2026 • By the Go Micro Team*
|
||||
|
||||
Go Micro v6.3.15 is a small but useful harness release: less friction for the first agent, better streaming provider coverage, and more reliable execution when models and delegates do not behave perfectly.
|
||||
|
||||
## Anthropic now streams
|
||||
|
||||
The Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider. That means Go Micro agents can use Anthropic in the same streaming path as the other streaming-capable providers, with request/response parser coverage and provider capability docs kept in sync.
|
||||
|
||||
## The first-agent path is easier to start
|
||||
|
||||
The on-ramp now has a smallest runnable first-agent example: a mock-model, no-secret agent you can run before adding provider keys. The CLI and docs also point new users toward the maintained first-agent path after scaffold/run milestones, so the next step after a service is clearer: run the example, build the agent, debug it, then walk the 0→hero services → agents → workflows path.
|
||||
|
||||
## Plan/delegate is more deterministic
|
||||
|
||||
Plan/delegate runs got another reliability pass. Completed plan steps are preserved, ordering is guarded, notify-before-completion is required in the flow path, and checkpoint continuation is more stable. These are the kinds of harness fixes that matter when an agent does real work over multiple tool calls instead of just answering a prompt.
|
||||
|
||||
## Tool-call recovery keeps improving
|
||||
|
||||
Provider text tool-call fallback paths now recover more of the awkward cases: tagged calls, `Create`-suffixed calls, mixed text/tool-call output, and AtlasCloud follow-up calls. The goal is pragmatic: when a weaker or non-standard provider emits something close to a tool call, the harness should still make progress when it can do so safely.
|
||||
|
||||
## A2A payment groundwork
|
||||
|
||||
The A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. It is groundwork, not a full product story yet, but it keeps Go Micro's agent interop and paid-tool direction moving together.
|
||||
|
||||
## Read the changelog
|
||||
|
||||
The full release notes are in the [CHANGELOG](https://github.com/micro/go-micro/blob/master/CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is an open source agent harness and service framework for Go. [Star us on GitHub](https://github.com/micro/go-micro).*
|
||||
|
||||
<div class="post-nav">
|
||||
<div><a href="/blog/33">← The Loop, Shipped: Introducing micro loop</a></div>
|
||||
<div><a href="/blog/">All Posts</a></div>
|
||||
</div>
|
||||
@@ -11,6 +11,13 @@ permalink: /blog/
|
||||
|
||||
<div class="posts">
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/34">What's New in Go Micro: v6.3.15</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">July 5, 2026</p>
|
||||
<p>Go Micro v6.3.15 tightens the first-agent on-ramp, adds Anthropic streaming, hardens plan/delegate execution, and improves provider text tool-call recovery.</p>
|
||||
<a href="/blog/34">Read more →</a>
|
||||
</article>
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/33">The Loop, Shipped: Introducing micro loop</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">July 2, 2026</p>
|
||||
|
||||
@@ -5,27 +5,29 @@ title: AI Integration
|
||||
|
||||
# AI Integration
|
||||
|
||||
Go Micro is an AI-native microservices framework. Every service you build is automatically accessible to AI agents, and every service can call AI models. This page explains how the pieces fit together.
|
||||
Go Micro is an agent harness and service framework for Go. Every service you build can become an AI-callable tool, every agent runs as a service with model/memory/guardrails around it, and flows orchestrate the deterministic parts. This page explains how the services → agents → workflows lifecycle fits together.
|
||||
|
||||
<img src="/images/generated/mcp-agent.jpg" alt="AI integration architecture" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
|
||||
|
||||
## The Stack
|
||||
|
||||
```
|
||||
Your Services → write Go handlers, register with the framework
|
||||
Services → write Go handlers, register with the framework
|
||||
↓
|
||||
Registry → automatic service discovery (mDNS, Consul, etcd)
|
||||
Registry → automatic discovery for services, agents, and flows
|
||||
↓
|
||||
Gateways → micro api (HTTP→RPC) / micro mcp (MCP tools)
|
||||
Gateways → micro api (HTTP→RPC), micro mcp (tools), micro a2a (agents)
|
||||
↓
|
||||
ai.Tools → discovers services + executes RPCs programmatically
|
||||
↓
|
||||
ai.Model → calls LLMs (Anthropic, OpenAI, Gemini, Atlas Cloud, ...)
|
||||
↓
|
||||
agent / flow / micro chat → agent-managed, event-driven, or interactive orchestration
|
||||
Agents → service-backed model loop with memory, guardrails, plan/delegate
|
||||
↓
|
||||
Flows → durable deterministic steps that can dispatch to agents
|
||||
```
|
||||
|
||||
Every layer is optional. You can use go-micro without AI. You can use the `ai` package without MCP. But when you stack them, you get services that AI agents can discover and orchestrate automatically.
|
||||
Every layer is optional. You can use Go Micro as a service framework without AI. You can use the `ai` package without MCP. But when you stack them, you get one runtime where services become tools, agents are reachable services, and workflows coordinate the predictable parts.
|
||||
|
||||
## Layer by Layer
|
||||
|
||||
|
||||
@@ -2,75 +2,124 @@
|
||||
layout: default
|
||||
---
|
||||
|
||||
## Architecture
|
||||
# Architecture
|
||||
|
||||
<img src="/images/generated/architecture.jpg" alt="Go Micro architecture" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
|
||||
|
||||
An overview of the Go Micro architecture.
|
||||
Go Micro is one runtime for the services → agents → workflows lifecycle. The same
|
||||
registry, client/server RPC, store, broker, and gateway primitives that run a
|
||||
service also give an agent discoverable tools, durable state, interop, and a
|
||||
place to hand off deterministic work.
|
||||
|
||||
## Overview
|
||||
## Lifecycle map
|
||||
|
||||
Go Micro abstracts away the details of distributed systems. Here are the main features.
|
||||
```text
|
||||
Services → Agents → Workflows
|
||||
handlers model loop durable orchestration
|
||||
registry memory triggers and ordered steps
|
||||
RPC tools guardrails agent dispatch
|
||||
```
|
||||
|
||||
- **Authentication** - Auth is built in as a first class citizen. Authentication and authorization enable secure
|
||||
zero trust networking by providing every service an identity and certificates. This additionally includes rule
|
||||
based access control.
|
||||
The layers are progressive: start with a service, expose its endpoints as tools,
|
||||
wrap those tools with an agent, then move the known paths into flows so the model
|
||||
only handles the uncertain parts.
|
||||
|
||||
- **Dynamic Config** - Load and hot reload dynamic config from anywhere. The config interface provides a way to load application
|
||||
level config from any source such as env vars, file, etcd. You can merge the sources and even define fallbacks.
|
||||
## Service substrate
|
||||
|
||||
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
|
||||
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
|
||||
Go Micro's service framework supplies the distributed-systems base every agent
|
||||
needs:
|
||||
|
||||
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
|
||||
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
|
||||
multicast DNS (mdns), a zeroconf system.
|
||||
- **Registry** — services, agents, and flows register under names so clients,
|
||||
gateways, and other agents can discover them without hard-coded addresses. The
|
||||
default is mDNS for local development, with pluggable backends for production.
|
||||
- **RPC client/server** — endpoints are normal Go handlers reached through the
|
||||
client, load balanced through discovery, encoded through codecs, and optionally
|
||||
streamed.
|
||||
- **Broker** — asynchronous events connect services and trigger flows without
|
||||
coupling producers to consumers.
|
||||
- **Config and auth** — dynamic configuration plus identity and authorization keep
|
||||
local and production runtimes using the same shape.
|
||||
- **Pluggable interfaces** — registry, broker, store, transport, codecs, auth, and
|
||||
config are Go interfaces, so the runtime can stay stable while deployments swap
|
||||
infrastructure.
|
||||
|
||||
- **Load Balancing** - Client side load balancing built on service discovery. Once we have the addresses of any number of instances
|
||||
of a service we now need a way to decide which node to route to. We use random hashed load balancing to provide even distribution
|
||||
across the services and retry a different node if there's a problem.
|
||||
That substrate is intentionally not separate from the agent stack. A service
|
||||
endpoint is the smallest useful unit of work, and the registry is the source of
|
||||
truth for which tools and agents exist.
|
||||
|
||||
- **Message Encoding** - Dynamic message encoding based on content-type. The client and server will use codecs along with content-type
|
||||
to seamlessly encode and decode Go types for you. Any variety of messages could be encoded and sent from different clients. The client
|
||||
and server handle this by default. This includes protobuf and json by default.
|
||||
## Agent harness
|
||||
|
||||
- **RPC Client/Server** - RPC based request/response with support for bidirectional streaming. We provide an abstraction for synchronous
|
||||
communication. A request made to a service will be automatically resolved, load balanced, dialled and streamed.
|
||||
Agents compose the service substrate with the AI-specific packages:
|
||||
|
||||
- **Async Messaging** - PubSub is built in as a first class citizen for asynchronous communication and event driven architectures.
|
||||
Event notifications are a core pattern in micro service development. The default messaging system is a HTTP event message broker.
|
||||
- **`model` / `ai.Model`** — a pluggable model interface normalizes provider calls
|
||||
while letting applications pick Anthropic, OpenAI, Gemini, Atlas Cloud, Groq,
|
||||
Mistral, Together AI, or a mock model for no-secret tests.
|
||||
- **`store` / memory** — agent history, plans, run state, and compacted memory live
|
||||
in durable storage rather than in an in-process chat loop.
|
||||
- **`ai.Tools`** — discovers registered service endpoints and executes them through
|
||||
the Go Micro client, so tools are generated from running services instead of a
|
||||
parallel tool registry.
|
||||
- **`agent`** — runs the tool-calling loop with guardrails, planning, delegation,
|
||||
service-backed memory, and an `Agent.Chat` RPC endpoint. An agent is therefore a
|
||||
service other clients and agents can call.
|
||||
|
||||
- **Pluggable Interfaces** - Go Micro makes use of Go interfaces for each distributed system abstraction. Because of this these interfaces
|
||||
are pluggable and allows Go Micro to be runtime agnostic. You can plugin any underlying technology.
|
||||
The result is a harness, not just a prompt loop: model calls are bounded by tool
|
||||
scope, state is recoverable, and the same CLI and gateways that reach services can
|
||||
reach agents.
|
||||
|
||||
## Design
|
||||
## Workflows
|
||||
|
||||
We will share more on architecture soon
|
||||
Use `flow` when the path is known or must be repeatable. Flows subscribe to broker
|
||||
events, run ordered deterministic steps, and can dispatch to an agent at the point
|
||||
where judgment or language understanding is needed. This keeps long-running work
|
||||
observable and restartable while preserving agents for open-ended decisions.
|
||||
|
||||
A common shape is:
|
||||
|
||||
1. A service emits an event such as `ticket.created`.
|
||||
2. A flow validates and enriches the event with deterministic handlers.
|
||||
3. The flow dispatches to an agent for classification, drafting, or escalation.
|
||||
4. The agent calls registered service tools and returns to the flow for final
|
||||
durable steps.
|
||||
|
||||
## Interop gateways
|
||||
|
||||
Gateways project the same runtime to external callers:
|
||||
|
||||
- **`micro api`** exposes service RPC over HTTP.
|
||||
- **`micro mcp`** exposes registered service endpoints as Model Context Protocol
|
||||
tools for external agents.
|
||||
- **`micro a2a`** exposes registered Go Micro agents through the Agent2Agent
|
||||
protocol and lets Go Micro flows or agents dispatch to agents hosted elsewhere.
|
||||
|
||||
MCP is the services-as-tools boundary; A2A is the agents-as-agents boundary. Both
|
||||
come from registry metadata, so adding a service or agent updates the external
|
||||
surface without duplicate wiring.
|
||||
|
||||
## Developer path
|
||||
|
||||
If you are new, follow the architecture in the same order the runtime composes it:
|
||||
|
||||
1. [Install troubleshooting](guides/install-troubleshooting.html) — make sure the
|
||||
CLI, `PATH`, version, and no-secret smoke path are healthy.
|
||||
2. [`micro agent demo`](getting-started.html#first-agent-on-ramp) — print the
|
||||
provider-free first-agent command and next docs steps from the installed CLI.
|
||||
3. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
— run one service-backed agent with a mock model.
|
||||
4. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — see the
|
||||
maintained support-agent path work without a provider key.
|
||||
5. [Your First Agent](guides/your-first-agent.html) — build and chat with a
|
||||
service-backed agent.
|
||||
6. [Debugging your agent](guides/debugging-agents.html) — inspect service
|
||||
registration, tools, memory, providers, and run history.
|
||||
7. [0→hero Reference](guides/zero-to-hero.html) — walk scaffold → run → chat →
|
||||
inspect → flow → deploy dry-run as the maintained lifecycle contract.
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR Index](architecture/index.md)
|
||||
- [AI Integration](ai-integration.html) — layer-by-layer services → agents → workflows wiring
|
||||
- [Getting Started](getting-started.html) — first service and first-agent on-ramp
|
||||
- [Examples](examples/) — runnable examples mapped to the lifecycle
|
||||
- [ADR Index](architecture/index.md) — architecture decision records
|
||||
- [Configuration](config.html)
|
||||
- [Plugins](plugins.html)
|
||||
|
||||
## Example Usage
|
||||
|
||||
Here's a minimal Go Micro service demonstrating the architecture:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"go-micro.dev/v6"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("example",
|
||||
)
|
||||
service.Init()
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -10,9 +10,12 @@ agents → workflows lifecycle.
|
||||
|
||||
## Start here
|
||||
|
||||
For the provider-free first-agent route, run [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent), then follow [No-secret First Agent](../guides/no-secret-first-agent.html), [Your First Agent](../guides/your-first-agent.html), [Debugging your agent](../guides/debugging-agents.html), and the [0→hero Reference](../guides/zero-to-hero.html).
|
||||
|
||||
| Goal | Runnable example | Why it is useful |
|
||||
| --- | --- | --- |
|
||||
| 0→1 service | [`examples/hello-world`](https://github.com/micro/go-micro/tree/master/examples/hello-world) | Smallest RPC service with a client call and health checks. |
|
||||
| Provider-free first agent | [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent) | Smallest service-backed agent with a deterministic mock model; no provider key required. |
|
||||
| First service-backed agent | [`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo) | Multi-service project/task/team app with agent playground integration. |
|
||||
| 0→hero lifecycle | [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) | No-secret support-desk story: typed services, an agent, an event-driven flow, and a guardrail. |
|
||||
| Planning and delegation | [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) | Two agents collaborate through `plan` and `delegate` over normal Go Micro RPC. |
|
||||
@@ -25,10 +28,16 @@ agents → workflows lifecycle.
|
||||
- [Getting Started](../getting-started.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
to see the full lifecycle before generating your own service.
|
||||
- [No-secret First Agent](../guides/no-secret-first-agent.html) → run
|
||||
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
first for the smallest provider-free agent transcript.
|
||||
- [Your First Agent](../guides/your-first-agent.html) → run
|
||||
[`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo)
|
||||
or [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
when you want a complete service-backed agent to inspect.
|
||||
- [Debugging your agent](../guides/debugging-agents.html) → keep
|
||||
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
nearby as the smallest mock-model reproduction before inspecting richer runs.
|
||||
- [0→hero Reference](../guides/zero-to-hero.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
for the human-readable scenario, then `make harness` for the full CI contract.
|
||||
|
||||
@@ -17,15 +17,7 @@ Go Micro has three core abstractions:
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** for development. The `curl` install below gives you the `micro` binary without Go, but `micro run` compiles your services, so you'll want Go installed to build them.
|
||||
- An **LLM provider key** (Anthropic, OpenAI, Gemini, …) *only* for the AI features — `micro run --prompt`, `micro chat`, and agents. Plain services need no key. Set it before running, e.g. `export ANTHROPIC_API_KEY=sk-ant-...`.
|
||||
|
||||
Before your first provider-backed agent run, check the local path with:
|
||||
|
||||
```bash
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
The preflight is read-only: it verifies Go 1.24+, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider. When a check fails it prints the exact fix plus the next guide to open, so the scaffold → run → chat path stays walkable.
|
||||
- **No LLM provider key is required** for the first run below. Add an Anthropic, OpenAI, Gemini, or other provider key only when you reach the provider-backed generation and chat steps.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -37,66 +29,45 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
## Quick Start: Generate from a Prompt
|
||||
If install or shell setup fails, start with [Install troubleshooting](guides/install-troubleshooting.html) to verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path.
|
||||
|
||||
Prefer to start from a runnable reference? Clone the repository and run the maintained support-desk lifecycle example first:
|
||||
## Quick Start: Scaffold, Run, Call
|
||||
|
||||
Start with the path that proves the runtime works before any provider setup: install the CLI, scaffold one service, run it locally, then call it through the gateway.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/micro/go-micro.git
|
||||
cd go-micro
|
||||
go run ./examples/support
|
||||
micro new helloworld
|
||||
cd helloworld
|
||||
micro run
|
||||
```
|
||||
|
||||
That example is the no-secret 0→hero path: services expose ticket/customer/notification tools, an agent handles the work, and an event-driven flow triggers the agent. See [Learn by Example](examples/) when you want more runnable starting points.
|
||||
|
||||
Describe what you need. The AI designs services, writes handlers, compiles, and starts them:
|
||||
In another terminal, call the generated service:
|
||||
|
||||
```bash
|
||||
micro run --prompt "task management system"
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
You'll see the design, confirm, and services + agent start:
|
||||
That install → scaffold → run → call loop is the 0→1 contract. It requires Go and the `micro` binary, but no LLM key. Once this succeeds, you know the local runtime, hot reload, gateway, and service registration are working.
|
||||
|
||||
```text
|
||||
Services:
|
||||
● task — Core task management
|
||||
● project — Project organization
|
||||
|
||||
Generate? [Y/n]
|
||||
|
||||
Micro
|
||||
Services:
|
||||
● task
|
||||
● project
|
||||
Agents:
|
||||
◆ agent
|
||||
```
|
||||
|
||||
The interactive console lets you talk to your services immediately:
|
||||
|
||||
```text
|
||||
> Create a project called Launch, then add a task called 'Write docs'
|
||||
|
||||
→ project_Project_Create({"name":"Launch"})
|
||||
← {"record":{"id":"p1..."},"success":true}
|
||||
→ task_Task_Create({"title":"Write docs","project_id":"p1..."})
|
||||
|
||||
Created project Launch and added task 'Write docs' to it.
|
||||
```
|
||||
|
||||
The console discovers services from the registry and orchestrates across them via the agent. Use `micro run -d` for detached mode without the console, or `micro chat` as a standalone command.
|
||||
|
||||
### First-agent on-ramp
|
||||
|
||||
After this quick start, follow the agent path in order:
|
||||
|
||||
1. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
|
||||
2. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
3. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
4. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
5. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
1. [Install troubleshooting](guides/install-troubleshooting.html) — verify the CLI install before agent work.
|
||||
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](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support), and next interop examples from one map.
|
||||
7. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
|
||||
8. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
9. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
10. [Debugging your agent](guides/debugging-agents.html) — use `micro agent preflight` before `micro run`, `micro agent doctor` after `micro run`, then `micro chat` and `micro inspect agent <name>` to recover service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
11. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
|
||||
## Quick Start: Write a Service
|
||||
## Write a Service
|
||||
|
||||
Create and run a service manually:
|
||||
|
||||
@@ -160,6 +131,43 @@ micro new events --template pubsub
|
||||
micro new gateway --template api
|
||||
```
|
||||
|
||||
|
||||
## Generate from a Prompt — with an LLM key
|
||||
|
||||
After the no-secret path works, set a provider key if you want Go Micro to design services and an agent from a prompt:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
|
||||
micro run --prompt "task management system" --provider anthropic
|
||||
```
|
||||
|
||||
You'll see the design, confirm it, and then services plus an agent start:
|
||||
|
||||
```text
|
||||
Services:
|
||||
● task — Core task management
|
||||
● project — Project organization
|
||||
|
||||
Generate? [Y/n]
|
||||
|
||||
Micro
|
||||
Services:
|
||||
● task
|
||||
● project
|
||||
Agents:
|
||||
◆ agent
|
||||
```
|
||||
|
||||
Use the interactive console, `micro run -d` plus `micro chat`, or the agent playground to talk to the generated services.
|
||||
|
||||
Before your first provider-backed agent run, check the local path with:
|
||||
|
||||
```bash
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
The preflight is read-only: it verifies Go 1.24+, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider. When a check fails it prints the exact fix plus the next guide to open, so the scaffold → run → chat path stays walkable.
|
||||
|
||||
## Building Agents
|
||||
|
||||
For a complete service-backed walkthrough, start with [Your First Agent](guides/your-first-agent.html). If you want to run before you write, use [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the full services → agents → workflows lifecycle or [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) for the smallest multi-agent planning/delegation path.
|
||||
@@ -253,4 +261,5 @@ The flow discovers all services as tools and lets the LLM decide which RPCs to c
|
||||
- [Agent Design](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md) — the full agent interface specification
|
||||
- [MCP & AI Agents](mcp.html) — MCP gateway, tool discovery, and auth
|
||||
- [Data Model](model.html) — typed persistence with CRUD and queries
|
||||
- [`micro loop` quickstart](guides/micro-loop.html) — scaffold a CI-gated autonomous improvement loop for a repository
|
||||
- [Deployment](deployment.html) — deploy via SSH + systemd
|
||||
|
||||
@@ -30,23 +30,23 @@ imports are linked in:
|
||||
|
||||
```go
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
fmt.Printf("%s: chat=%t image=%t video=%t stream=%t\n", row.Provider, row.Model, row.Image, row.Video, row.Stream)
|
||||
fmt.Printf("%s: chat=%t image=%t video=%t stream=%t tool_stream=%t\n", row.Provider, row.Model, row.Image, row.Video, row.Stream, row.ToolStream)
|
||||
}
|
||||
```
|
||||
|
||||
The built-in providers currently register these capability interfaces:
|
||||
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | No |
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
| `minimax` | Yes | No | No | Yes |
|
||||
| `mistral` | Yes | No | No | Yes |
|
||||
| `ollama` | Yes | No | No | Yes |
|
||||
| `openai` | Yes | Yes | No | Yes |
|
||||
| `together` | Yes | No | No | Yes |
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) | Tool streaming |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | Yes | Yes |
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes | No |
|
||||
| `gemini` | Yes | No | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes | Yes |
|
||||
| `minimax` | Yes | No | No | Yes | Yes |
|
||||
| `mistral` | Yes | No | No | Yes | Yes |
|
||||
| `ollama` | Yes | No | No | Yes | Yes |
|
||||
| `openai` | Yes | Yes | No | Yes | Yes |
|
||||
| `together` | Yes | No | No | Yes | Yes |
|
||||
|
||||
## Step 1: Implement the `ai.Model` Interface
|
||||
|
||||
|
||||
@@ -17,9 +17,22 @@ micro inspect ... # read the recorded run or workflow history
|
||||
|
||||
Debug the lifecycle in the same order Go Micro runs it: first prove the service is
|
||||
registered and callable, then inspect the agent run that chose tools, then inspect
|
||||
any workflow that handed off to the agent. If the first local run fails before a
|
||||
chat turn, run `micro agent preflight`; failed checks include `Fix:` and `Next:`
|
||||
lines for Go, CLI installation, provider-key setup, and the local gateway port.
|
||||
any workflow that handed off to the agent.
|
||||
|
||||
Use the recovery command that matches where you are in the first-agent journey:
|
||||
|
||||
| Checkpoint | When to use it | Command |
|
||||
| --- | --- | --- |
|
||||
| Install troubleshooting | `micro` is not installed, not on `PATH`, or the shell cannot run it. | [Install troubleshooting](install-troubleshooting.html) |
|
||||
| Quick recovery map | The first-agent loop stalled and you want the short scaffold → run → chat → inspect checklist before reading this full guide. | `micro agent quickcheck` (alias: `micro agent debug`) |
|
||||
| Preflight before `micro run` | You have not started the local runtime yet and want to verify Go, CLI, provider-key, and gateway-port prerequisites. | `micro agent preflight` |
|
||||
| Doctor after `micro run` | `micro run` is active, but chat, the `/agent` gateway, agent registration, provider settings, or inspect/run history is not behaving. | `micro agent doctor` |
|
||||
|
||||
`micro agent quickcheck` is the quickest breadcrumb when you are unsure where the first-agent path failed: it prints the preflight, run, doctor, inspect, and no-secret fallback commands in one place. `micro agent preflight` is read-only and runs before the first local run; failed
|
||||
checks include `Fix:` and `Next:` lines for Go, CLI installation, provider-key
|
||||
setup, and the local gateway port. Once `micro run` is already up, switch to
|
||||
`micro agent doctor` so the recovery output follows the live gateway, chat
|
||||
settings, registered agents, provider configuration, and inspectable run history.
|
||||
|
||||
## 1. Reproduce one small turn
|
||||
|
||||
@@ -104,6 +117,18 @@ state (`agent/<name>/runs/...`). The persisted timeline is recorded even without
|
||||
an OpenTelemetry exporter, so `micro inspect agent` remains useful in local
|
||||
no-secret development.
|
||||
|
||||
Provider-free quickcheck: if you want to verify the documented inspect path
|
||||
before involving a live model, run the same smoke check CI uses:
|
||||
|
||||
```sh
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
|
||||
```
|
||||
|
||||
That test seeds a local `assistant` run history and memory transcript, then runs
|
||||
`micro inspect agent assistant --limit 1`, `micro inspect agent --status done
|
||||
--json assistant`, and `micro agent history assistant` with provider credentials
|
||||
cleared.
|
||||
|
||||
## 4. See tool calls as they happen
|
||||
|
||||
When you are embedding an agent in Go and need live tool visibility, use the
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
layout: default
|
||||
title: Install troubleshooting
|
||||
---
|
||||
|
||||
# Install troubleshooting
|
||||
|
||||
Use this page before `micro new` or `micro agent demo` when the CLI install is
|
||||
unclear. The goal is to prove three boundaries in order: the `micro` binary is on
|
||||
`PATH`, it is the version you expected, and the no-secret first-run path works
|
||||
without provider keys.
|
||||
|
||||
## 1. Choose one install path
|
||||
|
||||
### Binary installer (no Go required to install)
|
||||
|
||||
```sh
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
```
|
||||
|
||||
Use this when you want the released `micro` binary without building it yourself.
|
||||
The generated services still need a Go toolchain when you run `micro run`, but the
|
||||
installer itself does not require Go.
|
||||
|
||||
### Go install (build from source)
|
||||
|
||||
```sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
Use this when Go is already installed and you want the binary in your Go bin
|
||||
directory. If the command succeeds but `micro` is not found, your Go bin directory
|
||||
is probably not on `PATH`.
|
||||
|
||||
## 2. Verify `PATH` and version
|
||||
|
||||
Check which binary your shell will run:
|
||||
|
||||
```sh
|
||||
command -v micro
|
||||
micro --version
|
||||
```
|
||||
|
||||
If `command -v micro` prints nothing, add the install directory to `PATH`, then
|
||||
open a new terminal and retry. Common locations are:
|
||||
|
||||
```sh
|
||||
export PATH="$HOME/.micro/bin:$PATH" # binary installer
|
||||
export PATH="$(go env GOPATH)/bin:$PATH" # go install
|
||||
```
|
||||
|
||||
If `micro --version` shows an older binary than expected, remove the stale copy or
|
||||
put the intended install directory earlier in `PATH`.
|
||||
|
||||
## 3. Run the no-secret smoke path
|
||||
|
||||
Once `micro` resolves, prove the local service runtime before adding LLM provider
|
||||
keys:
|
||||
|
||||
```sh
|
||||
micro new helloworld
|
||||
cd helloworld
|
||||
micro run
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
This checks the scaffold, local build, gateway, and service registration without
|
||||
calling a model provider.
|
||||
|
||||
## 4. Recover common failures
|
||||
|
||||
| Symptom | Check | Fix |
|
||||
|---------|-------|-----|
|
||||
| `micro: command not found` | `command -v micro` | Add the installer bin directory or `$(go env GOPATH)/bin` to `PATH`, then open a new terminal. |
|
||||
| `micro run` cannot find Go | `go version` | Install Go 1.24 or newer from <https://go.dev/doc/install>. |
|
||||
| The gateway port is busy | `lsof -i :8080` | Stop the process using the port, or run with a different address. |
|
||||
| Provider-key errors block an agent run | `micro agent preflight` | Stay on the no-secret path first: run `micro agent demo`, then the no-secret first-agent guide. |
|
||||
|
||||
## 5. Continue the first-agent on-ramp
|
||||
|
||||
After install verification succeeds, continue in order:
|
||||
|
||||
1. `micro agent demo` — print the provider-free first-agent demo command and next docs steps.
|
||||
2. [No-secret first-agent transcript](no-secret-first-agent.html) — prove an agent can use services without a provider key.
|
||||
3. [Your First Agent](your-first-agent.html) — build and chat with your own service-backed agent.
|
||||
4. [Debugging your agent](debugging-agents.html) — inspect registration, tool calls, run history, and provider failures.
|
||||
5. [0→hero Reference](zero-to-hero.html) — walk the full services → agents → workflows lifecycle.
|
||||
|
||||
For repository contributors, `make install-smoke` runs the same installer seam
|
||||
against a local build without network access.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# `micro loop` quickstart
|
||||
|
||||
`micro loop` scaffolds the autonomous improvement loop that Go Micro uses on
|
||||
this repository: GitHub Actions workflows for planning, building, evaluation
|
||||
feedback, coherence, security, and release. Use it when you want a repository to
|
||||
continuously turn a ranked queue into small PRs while CI remains the merge gate.
|
||||
|
||||
## 1. Initialize the loop
|
||||
|
||||
Run the default loop from the repository root:
|
||||
|
||||
```bash
|
||||
micro loop init
|
||||
```
|
||||
|
||||
For every role used by Go Micro itself, scaffold all workflows:
|
||||
|
||||
```bash
|
||||
micro loop init --roles all
|
||||
```
|
||||
|
||||
The command writes:
|
||||
|
||||
- `.github/loop/NORTH_STAR.md` — the direction every increment should optimize.
|
||||
- `.github/loop/PRIORITIES.md` — the ranked queue; the builder takes the top open issue.
|
||||
- `.github/loop/prompts/*.md` — editable policy for planner, builder, triage, coherence, and security roles.
|
||||
- `.github/workflows/loop-*.yml` — generated GitHub Actions mechanics.
|
||||
|
||||
Edit the files under `.github/loop/` to steer the loop. Re-run
|
||||
`micro loop init --roles all --force` only when you want to regenerate workflow
|
||||
mechanics from the installed CLI.
|
||||
|
||||
## 2. Configure the dispatch token
|
||||
|
||||
The scheduled builder needs a repository secret containing a token from a user
|
||||
account that the coding agent will answer. Go Micro names that secret
|
||||
`CODEX_TRIGGER_TOKEN` by default. If you use another secret name, pass it when
|
||||
you initialize the loop:
|
||||
|
||||
```bash
|
||||
micro loop init --agent @codex --token-secret LOOP_TOKEN --roles all
|
||||
```
|
||||
|
||||
The token needs enough repository permission to open issues, comment, push
|
||||
branches, create pull requests, and enable auto-merge. Run `gh auth setup-git` in
|
||||
the environment that will push branches so `git push` uses the same credentials
|
||||
as `gh`.
|
||||
|
||||
## Choosing an agent
|
||||
|
||||
The loop is **agent-agnostic by design**. Each run opens a fresh tracking issue
|
||||
and summons the agent with an `@mention` comment; the prompt file
|
||||
(`.github/loop/prompts/<role>.md`) is the instruction. Any coding agent that
|
||||
(a) responds to an `@mention` on an issue and (b) can open a PR with `gh` works —
|
||||
you select it with `--agent`.
|
||||
|
||||
- **Codex** (`--agent @codex`, the default). Point `--token-secret` at a PAT for
|
||||
the user account Codex follows, and make sure the Codex environment installs
|
||||
`gh` and runs `gh auth setup-git`. This is the path Go Micro itself runs on.
|
||||
- **Claude Code** (`--agent @claude`). Install
|
||||
[`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action)
|
||||
in the repo so a workflow responds to `@claude` comments and runs Claude with a
|
||||
repo-scoped token; then the loop's dispatch triggers it like any other mention.
|
||||
- **Any other mention-driven agent** — pass its handle to `--agent`. The
|
||||
mechanics don't care which agent it is.
|
||||
|
||||
Not supported by the mention model: agents triggered by **issue assignment**
|
||||
rather than a comment (e.g. GitHub Copilot's coding agent, which you assign an
|
||||
issue to). The dispatch would need an "assign" adapter for those; it isn't wired
|
||||
yet, so stick to mention-driven agents.
|
||||
|
||||
## 3. Make CI the gate
|
||||
|
||||
The loop should not be its own reviewer. Protect the default branch so PRs merge
|
||||
only after the required checks pass. At minimum, require the same commands the
|
||||
Go Micro loop verifies locally and in CI:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
If your repository has a harness or end-to-end grader, make that required too.
|
||||
Keep human approval requirements out of the autonomous path unless you intend the
|
||||
loop to pause for review.
|
||||
|
||||
## 4. Verify the wiring
|
||||
|
||||
After editing the North Star, queue, prompts, token secret, and branch
|
||||
protection, run:
|
||||
|
||||
```bash
|
||||
micro loop verify
|
||||
```
|
||||
|
||||
`micro loop verify` checks that the loop direction, queue, prompts, role
|
||||
workflows, and non-loop CI gate are present. Fix any reported missing items
|
||||
before relying on scheduled increments.
|
||||
|
||||
## 5. Operate the queue
|
||||
|
||||
Keep one ranked list in `.github/loop/PRIORITIES.md`. Each item should link a
|
||||
scoped issue and be small enough for one PR. The builder closes both the priority
|
||||
issue and the per-run tracker issue in the PR body, for example:
|
||||
|
||||
```text
|
||||
Closes #1234
|
||||
Closes #5678
|
||||
```
|
||||
|
||||
Use the North Star to keep the queue honest: favor small improvements that move
|
||||
developers through the services → agents → workflows lifecycle, and surface
|
||||
breaking API or brand/positioning decisions for humans instead of auto-merging
|
||||
them.
|
||||
@@ -25,11 +25,23 @@ end to end with no secrets.
|
||||
|
||||
## Transcript
|
||||
|
||||
From a fresh clone of the repository:
|
||||
If you installed the CLI first, ask it for the no-secret path:
|
||||
|
||||
```sh
|
||||
micro agent demo
|
||||
```
|
||||
|
||||
From a fresh clone of the repository, first run the smallest service-backed agent:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/micro/go-micro.git
|
||||
cd go-micro
|
||||
go run ./examples/first-agent
|
||||
```
|
||||
|
||||
Then run the maintained support-agent transcript that exercises the full lifecycle:
|
||||
|
||||
```sh
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
@@ -56,9 +68,10 @@ trigger and inspect the work.
|
||||
|
||||
## CI-backed check
|
||||
|
||||
Run the same deterministic path as a focused test:
|
||||
Run the same deterministic paths as focused tests:
|
||||
|
||||
```sh
|
||||
go test ./examples/first-agent -run TestRunFirstAgent -count=1
|
||||
go test ./examples/support -run TestRunSupportMockSmoke -count=1
|
||||
```
|
||||
|
||||
@@ -87,6 +100,7 @@ CI keeps those CLI boundaries present with:
|
||||
|
||||
```sh
|
||||
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
|
||||
```
|
||||
|
||||
## Debug transcript checkpoint
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user