Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34748b0c8e | |||
| dfe9e436e8 | |||
| 72902ee6fb | |||
| 819403e940 | |||
| 58284c6230 | |||
| 66ce68a93f | |||
| 4bd181fc21 | |||
| 3143767d78 | |||
| 666fc4b794 | |||
| 7af73b06d7 | |||
| 0811234f3e | |||
| b64a7d248d | |||
| e7b08d914f | |||
| 3a9455f28c | |||
| 05e53ec38c | |||
| dbdd0e1d6f | |||
| 8146e29f7e | |||
| c110774dc7 | |||
| c0a5775fb5 | |||
| f844a23bb2 | |||
| 04e8759d41 | |||
| 78725135aa | |||
| 2ff64ff0b2 | |||
| 7a8e7cd9ae | |||
| b58eed1698 | |||
| c92c9cc244 | |||
| a2bf43e9ef | |||
| 49bac7e4a8 | |||
| 5aac7e3ca0 | |||
| d86585bf5c | |||
| 88e2b58711 | |||
| d259383645 | |||
| 2d7ee300a4 | |||
| 57fa4e3b7a | |||
| 0f1917f26b | |||
| 6e9c5e87e9 | |||
| d8bb892425 | |||
| 064a112c6b | |||
| 24d103c658 | |||
| 2d8e3da943 | |||
| 3081dab246 | |||
| fdc422c16e | |||
| 5e3f8db193 | |||
| f5bf5f7987 | |||
| 3ec12b7c72 | |||
| fa4d4b7f6a | |||
| 43beaafab5 | |||
| 21a20005cb | |||
| e1b3c587aa | |||
| da2bbab80c | |||
| 5a59e1ece4 | |||
| aea03ba9e7 | |||
| 54bc05e48f | |||
| 3ef0d98a16 | |||
| 5a6c8d8b30 | |||
| 1cd918c2b9 | |||
| e96d4a67bc | |||
| 8994fd03f6 | |||
| 0595130f16 | |||
| 37eccc425e | |||
| 010e0fe57c | |||
| 113f268268 | |||
| c10697f08d | |||
| f61f3dc04d | |||
| d9e6c68938 | |||
| 8847668c83 | |||
| e45bf3d114 | |||
| 98d1f58cd6 | |||
| 51158ed2a7 | |||
| 09bf82d4f6 | |||
| 7d77f31ae2 | |||
| 2dbf70d0ed | |||
| 136c24ad5f | |||
| dc93addb10 | |||
| 09b4b8cc85 | |||
| 7b8139ca3c | |||
| ea6f7f6976 | |||
| b25a9171c5 | |||
| 2fe4eb71d1 | |||
| c4e110c77f | |||
| e4bb9d6bc7 | |||
| 562deb0557 | |||
| 551beb0858 | |||
| d831ba2c45 | |||
| a14633b9e8 | |||
| 4cc1ebb315 | |||
| 38ff084756 | |||
| 585dd789a4 | |||
| 971bfdb954 | |||
| 57873cc5a5 | |||
| 86bfced452 | |||
| 8863d27623 | |||
| efb4b3d191 | |||
| b83b8d164f | |||
| 11ff58fec7 | |||
| f1504507a3 | |||
| a2a7ee17e0 | |||
| e704b0e61b | |||
| 317bb300ae | |||
| ca82b85955 | |||
| a99c776880 | |||
| b8ca921ae8 | |||
| 159963ab39 | |||
| 59397834b4 | |||
| ee996afd39 | |||
| 236c5851c2 | |||
| 5ee3a5224f | |||
| c9f1ac921f | |||
| c395010565 | |||
| dca263f301 | |||
| 3540bf5a50 | |||
| 0d86fd5bcd | |||
| 205d6a55a3 | |||
| 7588a427cc | |||
| 7b9937068d | |||
| f8d8cb39a7 | |||
| 87a0c0c93b | |||
| f31a7acf0b | |||
| 4279a5eed3 | |||
| c39a846d3b | |||
| b707c7e305 | |||
| 5bfc37708d | |||
| 4d852c5e36 | |||
| f7a3e8461e | |||
| 87eb540a04 | |||
| 48e05385c7 | |||
| cb10149f54 | |||
| 66438697b3 | |||
| 6e1c11ca74 | |||
| 44c08a6bd2 | |||
| 9c01202599 | |||
| b4cfe51115 | |||
| 2221834707 | |||
| 5813f53117 | |||
| 72e6161b02 | |||
| 1789819c39 | |||
| 8676dcacf2 | |||
| 22b3b9ca22 | |||
| 4ca8948ba4 | |||
| 28cbf0be7e | |||
| e8e6cb1210 | |||
| 2a06a043bd | |||
| 6eef58f815 | |||
| 6da799d64f |
@@ -48,4 +48,4 @@ Add any other context about the problem here.
|
||||
- [Troubleshooting Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
|
||||
- [Examples](https://github.com/micro/go-micro/tree/master/examples)
|
||||
- [API Reference](https://pkg.go.dev/go-micro.dev/v5)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
|
||||
|
||||
@@ -39,4 +39,4 @@ Add any other context, code examples, or screenshots about the feature request h
|
||||
- [Roadmap](https://github.com/micro/go-micro/blob/master/ROADMAP.md)
|
||||
- [Contributing Guide](https://github.com/micro/go-micro/blob/master/CONTRIBUTING.md)
|
||||
- [Architecture Docs](https://github.com/micro/go-micro/tree/master/internal/website/docs/architecture.md)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
|
||||
|
||||
@@ -3,8 +3,8 @@ name: Harness (E2E)
|
||||
# Runs the end-to-end harnesses for agents, services, flows, and provider
|
||||
# conformance. The default job uses deterministic mock LLMs and needs no
|
||||
# secrets. A second job runs the same harnesses against the live provider set and
|
||||
# fails if any selected provider secret is missing, so scheduled conformance
|
||||
# cannot silently degrade.
|
||||
# skips providers whose secrets are absent, so scheduled conformance
|
||||
# remains safe in no-key forks while still failing configured providers that drift.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,8 +12,22 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
providers:
|
||||
description: "Comma-separated providers for live conformance (default: all supported)"
|
||||
required: false
|
||||
default: "anthropic,openai,gemini,groq,mistral,together,atlascloud"
|
||||
harnesses:
|
||||
description: "Comma-separated harnesses for live conformance"
|
||||
required: false
|
||||
default: "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
|
||||
require_configured:
|
||||
description: "Fail selected live providers that do not have repository secrets"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
@@ -27,14 +41,8 @@ jobs:
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: 0→1 scaffold contract
|
||||
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
|
||||
- name: Universe end-to-end (asserts; exits non-zero on failure)
|
||||
run: go run ./internal/harness/universe
|
||||
- name: Agent-flow harness
|
||||
run: go run ./internal/harness/agent-flow
|
||||
- name: 0→hero plan-delegate workflow harness
|
||||
run: go run ./internal/harness/plan-delegate
|
||||
- name: 0→1 and 0→hero developer-flow harness
|
||||
run: make harness
|
||||
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM conformance)
|
||||
@@ -59,12 +67,42 @@ jobs:
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
# Atlas Cloud's default chat model was failing the agent/tool-use
|
||||
# conformance harnesses; run it against a stronger tool-use model.
|
||||
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
|
||||
# catalog id differs (Atlas uses org/model ids).
|
||||
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
|
||||
run: |
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-require-configured \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-summary-markdown provider-conformance-summary.md \
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
|
||||
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
|
||||
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
|
||||
|
||||
args=(
|
||||
-providers "$PROVIDERS"
|
||||
-harnesses "$HARNESSES"
|
||||
-summary-json provider-conformance-summary.json
|
||||
-summary-markdown provider-conformance-summary.md
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
)
|
||||
if [ "$REQUIRE_CONFIGURED" = "true" ]; then
|
||||
args+=( -require-configured )
|
||||
fi
|
||||
|
||||
go run ./internal/harness/provider-conformance "${args[@]}"
|
||||
- name: Publish provider conformance summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f provider-conformance-summary.md ]; then
|
||||
cat provider-conformance-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -f provider-capabilities.md ]; then
|
||||
{
|
||||
echo
|
||||
echo "## Registered provider capabilities"
|
||||
echo
|
||||
cat provider-capabilities.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Upload provider conformance summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Architecture Review
|
||||
name: "Loop: Architect (Planner)"
|
||||
|
||||
# Continuous high-altitude oversight of the whole framework and harness — the
|
||||
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
@@ -48,4 +48,4 @@ jobs:
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
|
||||
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — DEVELOPER ADOPTION: the framework's depth is strong but its ON-RAMP is the gap, and the strategic priority right now is developer adoption / reviving real usage. Weight the developer on-ramp and DX — a walkable first-agent tutorial, discoverable examples, docs wayfinding/nav, install friction, debugging, the 0→1 and 0→hero experience — AT LEAST as highly as internal hardening. A developer succeeding on their first agent matters more right now than another conformance/observability/interop increment; do NOT let the queue fill entirely with internal depth work — keep open adoption/on-ramp items near the top. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Continuous Improvement
|
||||
name: "Loop: Builder (Generator)"
|
||||
|
||||
# Durable backbone for the autonomous improvement loop
|
||||
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
@@ -1,4 +1,4 @@
|
||||
name: DevRel Review
|
||||
name: "Loop: DevRel"
|
||||
|
||||
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
|
||||
# website (landing + docs), and blog — part of the autonomous loop
|
||||
@@ -0,0 +1,76 @@
|
||||
name: "Loop: Release (daily patch)"
|
||||
|
||||
# Keeps the installable framework tracking the loop's daily improvements. Once a
|
||||
# day, if master has new commits since the latest v6 tag, this cuts the next
|
||||
# PATCH release (v6.MINOR.PATCH+1) and pushes the tag — which triggers the
|
||||
# existing goreleaser workflow (release.yml, tag-triggered) to build the release,
|
||||
# binaries, and images.
|
||||
#
|
||||
# The tag is pushed with a PAT (CODEX_TRIGGER_TOKEN), NOT the default GITHUB_TOKEN:
|
||||
# a tag pushed by GITHUB_TOKEN would not trigger release.yml (Actions blocks that
|
||||
# recursion). Minor/major bumps stay with the human (notable / breaking releases).
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 23 * * *" # daily 23:00 UTC — captures the day's merges (tunable)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: loop-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need full history + all tags
|
||||
- name: Cut the next patch release if there are new commits
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_TOKEN" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
|
||||
echo "A tag pushed by the default GITHUB_TOKEN would not trigger the"
|
||||
echo "goreleaser workflow, so a user PAT is required to cut releases."
|
||||
exit 0
|
||||
fi
|
||||
git fetch --tags --force
|
||||
|
||||
LATEST=$(git tag --list 'v6.*.*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo "no v6.x.x tag found — aborting so nothing weird gets tagged."
|
||||
exit 1
|
||||
fi
|
||||
echo "latest release tag: $LATEST"
|
||||
|
||||
COUNT=$(git rev-list --count "$LATEST"..HEAD)
|
||||
echo "commits on HEAD since $LATEST: $COUNT"
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "no new commits since $LATEST — no release today."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Bump the patch: v6.MINOR.PATCH -> v6.MINOR.(PATCH+1)
|
||||
ver="${LATEST#v}" # 6.3.10
|
||||
major="${ver%%.*}" # 6
|
||||
rest="${ver#*.}" # 3.10
|
||||
minor="${rest%%.*}" # 3
|
||||
patch="${rest#*.}" # 10
|
||||
case "$major.$minor.$patch" in
|
||||
[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)"
|
||||
|
||||
git config user.name "go-micro release bot"
|
||||
git config user.email "noreply@go-micro.dev"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated daily patch ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT. goreleaser (release.yml) will build and publish it."
|
||||
@@ -0,0 +1,59 @@
|
||||
name: "Loop: Triage (Evaluator feedback)"
|
||||
|
||||
# Closes the autonomous loop's feedback path: when the live provider-conformance
|
||||
# harness fails, dispatch Codex to TRIAGE the failing run and file scoped, deduped
|
||||
# issues that the hourly increment loop then fixes — no human in the middle. It
|
||||
# only triages the scheduled/manual live run (not every push/PR mock run), dedupes
|
||||
# against open issues so hourly repeats don't spam, ignores transient flakes, and
|
||||
# ESCALATES anything needing a breaking/architectural change as needs-human rather
|
||||
# than auto-building it.
|
||||
#
|
||||
# Gated on CODEX_TRIGGER_TOKEN like the rest of the loop (Codex ignores comments
|
||||
# authored by the github-actions bot).
|
||||
#
|
||||
# Note: Codex is serial, so this competes with the hourly increment + architect
|
||||
# dispatches for the single task slot. If it saturates, lower the harness cadence
|
||||
# or gate this to a slower schedule.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Harness (E2E)"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: harness-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
# Only when the harness actually failed, and only for the scheduled or manual
|
||||
# live run — never the per-push/PR mock run.
|
||||
if: github.event.workflow_run.conclusion == 'failure' && (github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch')
|
||||
steps:
|
||||
- name: Open a triage issue and dispatch Codex
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
|
||||
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
|
||||
exit 0
|
||||
fi
|
||||
# Ensure the escalation label exists (idempotent).
|
||||
gh label create needs-human --repo "$REPO" --color FBCA04 \
|
||||
--description "Requires a human/architect decision (breaking or architectural)" --force || true
|
||||
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Harness failure triage: run $RUN_ID" \
|
||||
--body "Automated triage of a failed live provider-conformance harness run: $RUN_URL")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened triage issue #$ISSUE_NUM — dispatching Codex."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Act as failure triage for the autonomous loop. The live provider-conformance harness failed: $RUN_URL (run id $RUN_ID). Do this, and do NOT change code or open a PR — triage only: (1) Read the failing logs (\`gh run view $RUN_ID --repo $REPO --log-failed\`) and the provider-conformance artifact/summary. (2) Root-cause each DISTINCT failure. (3) DEDUPE against existing work — list open issues (\`gh issue list --repo $REPO --label codex --state open --limit 100\`); if a matching issue already exists for a failure, add a one-line 'recurred in $RUN_URL' comment to it and do NOT open a duplicate. (4) For each genuine, self-contained, CI-verifiable defect that is NOT already tracked, open a scoped issue: \`gh issue create --repo $REPO --label codex --label enhancement --title \"<scoped task>\" --body \"<root cause, scope, acceptance criteria, and the failing run link>\"\` — the hourly increment loop will build it. (5) If a failure is transient/flaky and not a code defect (e.g. a live-model latency timeout or provider outage), note it in a comment and file NOTHING. (6) If a real fix would require a breaking public-API change or an architectural change, do NOT file it as an auto-buildable task — open an issue labeled \`needs-human\` describing it for the architect/human. When finished, close this triage issue (\`gh issue close $ISSUE_NUM\`)."
|
||||
+1
-1
@@ -19,7 +19,7 @@ else is additive. See the [v5 → v6 migration guide](internal/website/docs/guid
|
||||
- **JWT auth ported in-module.** The external `github.com/micro/plugins/v5/auth/jwt` (pinned to v5) is replaced by `go-micro.dev/v6/auth/jwt/token`, now on the maintained `golang-jwt/jwt/v5`; the deprecated `dgrijalva/jwt-go` dependency is dropped.
|
||||
|
||||
### Added
|
||||
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, and card discovery. `input-required` and `tasks/resubscribe` remain unsupported. (`gateway/a2a/`, `cmd/micro/a2a/`)
|
||||
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, `tasks/resubscribe`, `input-required` handoffs, and card discovery. (`gateway/a2a/`, `cmd/micro/a2a/`)
|
||||
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
|
||||
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
|
||||
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
|
||||
|
||||
@@ -6,6 +6,17 @@ Thank you for your interest in contributing to Go Micro! This document provides
|
||||
|
||||
Be respectful, inclusive, and collaborative. We're all here to build great software together.
|
||||
|
||||
## How Go Micro is built
|
||||
|
||||
Go Micro is developed by an **autonomous improvement loop** — a planner, a
|
||||
generator, and a separate evaluator, running as scheduled GitHub Actions with a
|
||||
human setting direction. It's the framework's own thesis (an agent operating a
|
||||
system) pointed at itself: an agent harness, built by agents. The full process —
|
||||
the planner → generator → evaluator pipeline, the correctness-only merge gate, and
|
||||
the guardrails — is documented in
|
||||
[`internal/docs/CONTINUOUS_IMPROVEMENT.md`](internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
Human contributions follow the same gate: green CI, one concern per PR.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
|
||||
@@ -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 provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
.PHONY: test test-race test-coverage harness provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -18,7 +18,8 @@ help:
|
||||
@echo " make test-race - Run tests with race detector"
|
||||
@echo " make test-coverage - Run tests with coverage"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make harness - Run deterministic end-to-end harnesses"
|
||||
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
|
||||
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
|
||||
@echo " make provider-conformance - Run harnesses against configured live providers"
|
||||
@echo " make fmt - Format code"
|
||||
@echo " make install-tools - Install development tools"
|
||||
@@ -42,12 +43,21 @@ test-coverage:
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report: coverage.html"
|
||||
|
||||
# Run the end-to-end harnesses (deterministic, mock LLM — no API key).
|
||||
# The universe harness exits non-zero on assertion failure.
|
||||
# Run the documented getting-started contracts plus the deterministic
|
||||
# services → agents → workflows harnesses (mock LLM — no API key).
|
||||
# This mirrors the default CI path so local dogfooding catches scaffold,
|
||||
# run/chat/inspect, and 0→hero regressions before a PR is opened.
|
||||
harness:
|
||||
go run ./internal/harness/universe
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
go run ./internal/harness/agent-flow
|
||||
go run ./internal/harness/plan-delegate # 0→hero: services + agents + flow + plan/delegate
|
||||
$(MAKE) provider-conformance-mock
|
||||
|
||||
# Run the shared provider conformance contract with the deterministic mock
|
||||
# provider. This is the no-secret path used by CI and local dogfooding to keep
|
||||
# provider-facing agent/tool semantics covered on every machine.
|
||||
provider-conformance-mock:
|
||||
go run ./internal/harness/provider-conformance -providers mock
|
||||
|
||||
# Run the same harnesses against every configured live provider. Providers
|
||||
# without API keys are skipped; configured providers must pass.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro) [](https://discord.gg/G8Gk5j3uXr)
|
||||
|
||||
Go Micro is an **agent harness** and service framework for Go.
|
||||
|
||||
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
|
||||
|
||||
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
|
||||
|
||||
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
|
||||
@@ -14,7 +16,7 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
|
||||
|
||||
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
|
||||
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
|
||||
|
||||
## Commercial Support
|
||||
|
||||
@@ -44,7 +46,7 @@ Install the CLI:
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
# Or with Go
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
### Fastest start — no API key
|
||||
@@ -64,6 +66,14 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
This scaffold → run → call path is covered by the no-secret CI harness. To run
|
||||
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
|
||||
chat/inspect CLI boundaries, and deploy dry-run), use:
|
||||
|
||||
```bash
|
||||
make harness
|
||||
```
|
||||
|
||||
### 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:
|
||||
@@ -393,8 +403,8 @@ Swap providers with a single import — same interface everywhere:
|
||||
| Google Gemini | `gemini-2.5-flash` |
|
||||
| Groq | `llama-3.3-70b-versatile` |
|
||||
| Mistral | `mistral-large-latest` |
|
||||
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `llama-3.3-70b` |
|
||||
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic", ai.WithAPIKey(key))
|
||||
@@ -415,6 +425,8 @@ See [all examples](examples/README.md).
|
||||
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [AI Integration](internal/website/docs/ai-integration.md)
|
||||
- [Your First Agent](internal/website/docs/guides/your-first-agent.md)
|
||||
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
|
||||
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
|
||||
- [Agent Design](internal/docs/AGENT_DESIGN.md)
|
||||
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ hosted service, enterprise tier, or venture funding. See
|
||||
## Contributing & feedback
|
||||
|
||||
Pick an item, open an issue to discuss the approach, and submit a PR. Or join the
|
||||
[Discord](https://discord.gg/WeMU5AGxD). Include tests, run `make test` and
|
||||
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
|
||||
`make lint`.
|
||||
|
||||
## Version support
|
||||
|
||||
+1
-1
@@ -174,6 +174,6 @@ 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/WeMU5AGxD
|
||||
- Join Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
)
|
||||
|
||||
func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
|
||||
var sawTool bool
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("model was not wired with agent tool handler")
|
||||
}
|
||||
result := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "echo",
|
||||
Input: map[string]any{"value": "a2a-stream"},
|
||||
})
|
||||
if !strings.Contains(result.Content, "a2a-stream-ok") {
|
||||
t.Fatalf("tool result = %q, want marker", result.Content)
|
||||
}
|
||||
return &ai.Response{Answer: "streamed " + result.Content}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("stream-agent"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
if info, ok := ai.RunInfoFrom(ctx); !ok || info.RunID == "" || info.Agent != "stream-agent" {
|
||||
t.Fatalf("RunInfo = %+v ok=%v, want stream-agent run", info, ok)
|
||||
}
|
||||
if input["value"] != "a2a-stream" {
|
||||
t.Fatalf("tool input = %+v, want a2a-stream", input)
|
||||
}
|
||||
return "a2a-stream-ok", nil
|
||||
}))
|
||||
h := a2a.NewAgentStreamHandler(
|
||||
a2a.Card("stream-agent", "http://example.invalid/stream-agent", "", nil),
|
||||
func(ctx context.Context, text string) (string, error) {
|
||||
resp, err := a.Ask(ctx, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
},
|
||||
a.streamAskAI,
|
||||
)
|
||||
|
||||
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"run stream tool"}],"kind":"message"}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if !sawTool {
|
||||
t.Fatal("A2A stream did not execute the agent tool path")
|
||||
}
|
||||
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "a2a-stream-ok") {
|
||||
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
var final struct {
|
||||
Result struct {
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
} `json:"status"`
|
||||
Artifacts []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &final); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
}
|
||||
if final.Error != nil {
|
||||
t.Fatalf("final event error: %+v", final.Error)
|
||||
}
|
||||
if final.Result.Status.State != "completed" {
|
||||
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
|
||||
}
|
||||
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
|
||||
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
|
||||
}
|
||||
}
|
||||
+69
-20
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
@@ -92,6 +93,11 @@ type agentImpl struct {
|
||||
// Ask. The model provider only sees a refused tool result; the agent
|
||||
// converts it into a durable paused run instead of completing the run.
|
||||
pause *approvalPause
|
||||
|
||||
// currentRun points at the checkpoint record for the Ask currently
|
||||
// holding mu. Tool execution updates it so resumed runs can reuse
|
||||
// completed tool results without replaying side effects.
|
||||
currentRun *flow.Run
|
||||
}
|
||||
|
||||
// New creates a new Agent.
|
||||
@@ -134,19 +140,35 @@ func (a *agentImpl) String() string {
|
||||
}
|
||||
|
||||
func (a *agentImpl) setup() {
|
||||
a.setupWithToolHandler(nil)
|
||||
}
|
||||
|
||||
func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
|
||||
if a.opts.Model != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
|
||||
}
|
||||
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
|
||||
// Reuse the existing tools instance: its name map is populated by
|
||||
// discoverTools, and rebuilding it here would orphan a base handler that
|
||||
// already captured the old instance (breaking StreamAsk tool resolution).
|
||||
if a.tools == nil {
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
}
|
||||
if handler == nil {
|
||||
handler = a.toolHandler()
|
||||
}
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(handler))
|
||||
a.model = ai.New(a.opts.Provider, modelOpts...)
|
||||
if a.model != nil {
|
||||
a.model = a.tracedModel(a.model)
|
||||
}
|
||||
|
||||
if a.mem != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Memory is pluggable. Use the configured one, otherwise the default
|
||||
// store-backed memory — except ephemeral sub-agents, which keep an
|
||||
// isolated, non-persistent context.
|
||||
@@ -156,7 +178,9 @@ func (a *agentImpl) setup() {
|
||||
case a.ephemeral:
|
||||
a.mem = NewInMemory(a.opts.HistoryLimit)
|
||||
case a.opts.MemoryCompaction.MaxMessages > 0:
|
||||
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
|
||||
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
|
||||
case a.opts.MemoryRetrievalLimit > 0:
|
||||
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
|
||||
default:
|
||||
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
|
||||
}
|
||||
@@ -202,17 +226,6 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
|
||||
})
|
||||
}
|
||||
|
||||
// Resume returns the response for a checkpointed agent run. Completed runs are
|
||||
// returned from the checkpoint without calling the model or replaying tool
|
||||
// calls; failed or in-progress runs continue from the saved input message.
|
||||
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resume(ctx, runID)
|
||||
}
|
||||
|
||||
// Pending returns checkpointed agent runs that have not completed. It mirrors
|
||||
// flow.Pending for startup recovery loops that drain durable agent work.
|
||||
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
@@ -231,16 +244,18 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
a.setup()
|
||||
}
|
||||
|
||||
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
|
||||
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
|
||||
}
|
||||
|
||||
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run) (*Response, error) {
|
||||
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
|
||||
toolList, err := a.discoverTools()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
|
||||
a.mem.Add("user", message)
|
||||
if addUserMessage {
|
||||
a.mem.Add("user", message)
|
||||
}
|
||||
a.steps = 0
|
||||
a.calls = map[string]int{}
|
||||
a.pause = nil
|
||||
@@ -253,10 +268,15 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
run := a.newCheckpointRun(runID, message, parentRunID, existing)
|
||||
a.currentRun = &run
|
||||
defer func() { a.currentRun = nil }()
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
if existing != nil {
|
||||
a.recordTimelineEvent(ctx, RunEvent{Time: time.Now(), RunID: runID, ParentID: parentRunID, Agent: a.opts.Name, Kind: "resume", Name: run.State.Stage})
|
||||
}
|
||||
defer func() { endRun(err) }()
|
||||
|
||||
messages := a.mem.Messages()
|
||||
@@ -280,8 +300,14 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
Backoff: a.opts.ModelRetryBackoff,
|
||||
})
|
||||
if err != nil {
|
||||
run.Status = "failed"
|
||||
run.Steps[0].Status = "failed"
|
||||
run.Status = agentRunFailureStatus(err)
|
||||
if a.currentRun != nil {
|
||||
run.Steps = a.currentRun.Steps
|
||||
}
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
||||
}
|
||||
run.Steps[0].Status = run.Status
|
||||
run.Steps[0].Error = err.Error()
|
||||
_ = a.saveRun(ctx, run)
|
||||
return nil, err
|
||||
@@ -290,6 +316,10 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
run.Status = "paused"
|
||||
run.State.Stage = agentApprovalStep
|
||||
run.State.Data = []byte(message)
|
||||
if a.pause.Tool == toolHumanInput {
|
||||
run.State.Stage = agentInputStep
|
||||
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
|
||||
}
|
||||
run.Steps[0].Status = "paused"
|
||||
run.Steps[0].Error = a.pause.Message
|
||||
run.Steps[0].Result = a.pause.Tool
|
||||
@@ -299,6 +329,19 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
|
||||
resp.ToolCalls = calls
|
||||
if resp.Answer == "" {
|
||||
resp.Answer = answer
|
||||
}
|
||||
trimmedReply := strings.TrimSpace(resp.Reply)
|
||||
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
|
||||
resp.Reply = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
a.mem.Add("assistant", resp.Reply)
|
||||
}
|
||||
@@ -326,6 +369,12 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
if b, marshalErr := json.Marshal(res); marshalErr == nil {
|
||||
run.State.Data = b
|
||||
}
|
||||
if a.currentRun != nil {
|
||||
run.Steps = a.currentRun.Steps
|
||||
}
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
||||
}
|
||||
run.Steps[0].Status = "done"
|
||||
run.Steps[0].Attempts++
|
||||
run.Steps[0].Result = reply
|
||||
@@ -392,7 +441,7 @@ func (a *agentImpl) Run() error {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
}, a.Stream)
|
||||
}, a.streamAskAI)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
|
||||
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
|
||||
|
||||
+155
-4
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
codecBytes "go-micro.dev/v6/codec/bytes"
|
||||
@@ -20,8 +21,9 @@ import (
|
||||
// the discovered service tools. There is no separate harness or graph:
|
||||
// the LLM calls them like any other tool.
|
||||
const (
|
||||
toolPlan = "plan"
|
||||
toolDelegate = "delegate"
|
||||
toolPlan = "plan"
|
||||
toolDelegate = "delegate"
|
||||
toolHumanInput = "request_input"
|
||||
)
|
||||
|
||||
// builtinTools returns the tool definitions exposed to the model in
|
||||
@@ -41,6 +43,18 @@ func builtinTools() []ai.Tool {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: toolHumanInput,
|
||||
OriginalName: toolHumanInput,
|
||||
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
|
||||
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
|
||||
Properties: map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The specific question, decision, or instruction needed from the human operator.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: toolDelegate,
|
||||
OriginalName: toolDelegate,
|
||||
@@ -78,6 +92,9 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
|
||||
case toolPlan:
|
||||
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
case toolHumanInput:
|
||||
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
case toolDelegate:
|
||||
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
@@ -97,13 +114,16 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
|
||||
// prevents runaway recursion).
|
||||
func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
if a.ephemeral {
|
||||
return a.tools.Handler()
|
||||
return a.toolTimeoutWrap(a.tools.Handler())
|
||||
}
|
||||
|
||||
// Innermost first: base, then guardrails (approve → loop → step →
|
||||
// plan), then developer wrappers outermost. Wrapping reverses order,
|
||||
// so the result runs plan → step → loop → approve → base.
|
||||
// so the result runs plan → step → loop → approve → checkpoint → base.
|
||||
h := a.baseHandler()
|
||||
h = a.toolTimeoutWrap(h)
|
||||
h = a.toolRetryWrap(h)
|
||||
h = a.checkpointToolWrap(h)
|
||||
h = a.approveWrap(h)
|
||||
h = a.loopWrap(h)
|
||||
h = a.stepWrap(h)
|
||||
@@ -131,6 +151,114 @@ func contextWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// toolTimeoutWrap gives each tool execution its own deadline while preserving
|
||||
// caller cancellation. Handlers still execute synchronously; tools that honor
|
||||
// context (custom tools, delegate RPC/A2A, and go-micro RPC clients) return
|
||||
// promptly with a bounded error result when the deadline expires.
|
||||
func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.ToolTimeout <= 0 {
|
||||
return next(ctx, call)
|
||||
}
|
||||
toolCtx, cancel := context.WithTimeout(ctx, a.opts.ToolTimeout)
|
||||
defer cancel()
|
||||
return next(toolCtx, call)
|
||||
}
|
||||
}
|
||||
|
||||
// toolRetryWrap retries transient tool failures with bounded backoff. It is
|
||||
// opt-in because tools can have side effects; guardrail refusals and caller
|
||||
// cancellation are never retried.
|
||||
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
maxAttempts := a.opts.ToolMaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
var res ai.ToolResult
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errResult(call.ID, err.Error())
|
||||
}
|
||||
res = next(ctx, call)
|
||||
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
|
||||
return annotateToolAttempts(res, attempt)
|
||||
}
|
||||
|
||||
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !t.Stop() {
|
||||
<-t.C
|
||||
}
|
||||
return errResult(call.ID, ctx.Err().Error())
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return annotateToolAttempts(res, maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func retryableToolResult(res ai.ToolResult) bool {
|
||||
if res.Refused != "" {
|
||||
return false
|
||||
}
|
||||
msg := toolErrorMessage(res)
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return ai.IsTransientError(fmt.Errorf("%s", msg))
|
||||
}
|
||||
|
||||
func toolErrorMessage(res ai.ToolResult) string {
|
||||
if m, ok := res.Value.(map[string]string); ok {
|
||||
return m["error"]
|
||||
}
|
||||
if m, ok := res.Value.(map[string]any); ok {
|
||||
if v, ok := m["error"].(string); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
var decoded map[string]string
|
||||
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
|
||||
return decoded["error"]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
|
||||
if attempts <= 1 {
|
||||
return res
|
||||
}
|
||||
res.Attempts = attempts
|
||||
if m, ok := res.Value.(map[string]string); ok {
|
||||
cp := map[string]any{}
|
||||
for k, v := range m {
|
||||
cp[k] = v
|
||||
}
|
||||
cp["attempts"] = attempts
|
||||
res.Value = cp
|
||||
if b, err := json.Marshal(cp); err == nil {
|
||||
res.Content = string(b)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
|
||||
if base <= 0 {
|
||||
base = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
base <<= shift
|
||||
}
|
||||
if base > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// baseHandler executes a tool call: a developer custom tool, the built-in
|
||||
// delegate, or an RPC to the service. It is the innermost handler.
|
||||
func (a *agentImpl) baseHandler() ai.ToolHandler {
|
||||
@@ -145,6 +273,9 @@ func (a *agentImpl) baseHandler() ai.ToolHandler {
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
|
||||
}
|
||||
}
|
||||
if call.Name == toolHumanInput {
|
||||
return a.handleHumanInput(call)
|
||||
}
|
||||
if call.Name == toolDelegate {
|
||||
return a.handleDelegate(ctx, call)
|
||||
}
|
||||
@@ -205,6 +336,11 @@ type approvalPause struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type inputPause struct {
|
||||
OriginalMessage string `json:"original_message"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Approve != nil {
|
||||
@@ -232,6 +368,17 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
}
|
||||
|
||||
// handleHumanInput records that the model needs operator input before it can continue.
|
||||
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
||||
prompt, _ := call.Input["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
prompt = "human input required"
|
||||
}
|
||||
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
|
||||
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
|
||||
}
|
||||
|
||||
// handleDelegate hands a subtask to another agent. Delegate-first:
|
||||
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
||||
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
||||
@@ -283,6 +430,10 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
WithRegistry(a.opts.Registry),
|
||||
WithClient(a.opts.Client),
|
||||
WithStore(a.opts.Store),
|
||||
ModelCallTimeout(a.opts.ModelTimeout),
|
||||
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
|
||||
ToolCallTimeout(a.opts.ToolTimeout),
|
||||
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
|
||||
TraceProvider(a.opts.TraceProvider),
|
||||
)
|
||||
// Record lineage so the sub-agent's tool calls carry this run as parent.
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
|
||||
func TestBuiltinTools(t *testing.T) {
|
||||
tools := builtinTools()
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 2", len(tools))
|
||||
if len(tools) != 3 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 3", len(tools))
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
names[tl.Name] = true
|
||||
}
|
||||
if !names[toolPlan] || !names[toolDelegate] {
|
||||
t.Errorf("builtin tools = %v, want plan and delegate", names)
|
||||
if !names[toolPlan] || !names[toolDelegate] || !names[toolHumanInput] {
|
||||
t.Errorf("builtin tools = %v, want plan, request_input, and delegate", names)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ func TestBuiltinsAccessor(t *testing.T) {
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
)
|
||||
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 2", len(tools))
|
||||
if len(tools) != 3 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 3", len(tools))
|
||||
}
|
||||
|
||||
// A name that isn't a built-in falls through (ok == false).
|
||||
|
||||
+152
-2
@@ -6,12 +6,14 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
const (
|
||||
agentAskStep = "ask"
|
||||
agentApprovalStep = "approval"
|
||||
agentInputStep = "input-required"
|
||||
)
|
||||
|
||||
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
|
||||
@@ -35,6 +37,7 @@ func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existin
|
||||
}
|
||||
run.Steps[0].Status = "in_progress"
|
||||
run.Steps[0].Error = ""
|
||||
run.Steps[0].Result = ""
|
||||
}
|
||||
return run
|
||||
}
|
||||
@@ -46,9 +49,26 @@ func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
||||
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
|
||||
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
||||
}
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
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,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume returns the response for a checkpointed agent run. Completed runs are
|
||||
// returned from the checkpoint without calling the model or replaying tool
|
||||
// calls; failed or in-progress runs continue from the saved input message.
|
||||
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resume(ctx, runID)
|
||||
}
|
||||
|
||||
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
@@ -61,6 +81,9 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
|
||||
return nil, fmt.Errorf("agent run %s not found", runID)
|
||||
}
|
||||
if run.Status == "paused" {
|
||||
if run.State.Stage == agentInputStep {
|
||||
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
|
||||
}
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
}
|
||||
@@ -71,6 +94,9 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
if terminalAgentRunStatus(run.Status) {
|
||||
return nil, fmt.Errorf("agent run %s is terminal with status %q", runID, run.Status)
|
||||
}
|
||||
message := string(run.State.Data)
|
||||
parentID := run.ParentID
|
||||
a.mu.Lock()
|
||||
@@ -78,7 +104,52 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, message, parentID, &run)
|
||||
return a.askLocked(ctx, run.ID, message, parentID, &run, false)
|
||||
}
|
||||
|
||||
// ResumeInput resumes a checkpointed agent run that paused via the built-in
|
||||
// request_input tool. The supplied input is appended to the original request so
|
||||
// the same run can continue with durable checkpoint and completed tool history.
|
||||
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resumeInput(ctx, runID, input)
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
}
|
||||
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent run %s not found", runID)
|
||||
}
|
||||
if run.Status != "paused" || run.State.Stage != agentInputStep {
|
||||
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
|
||||
}
|
||||
var p inputPause
|
||||
if err := run.State.Scan(&p); err != nil {
|
||||
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
|
||||
}
|
||||
message := p.OriginalMessage
|
||||
if message == "" {
|
||||
message = string(run.State.Data)
|
||||
}
|
||||
message += "\n\nHuman input: " + input
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
run.State.Data = []byte(message)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
|
||||
}
|
||||
|
||||
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
|
||||
@@ -91,9 +162,88 @@ func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
|
||||
}
|
||||
out := runs[:0]
|
||||
for _, run := range runs {
|
||||
if run.Flow == a.opts.Name && run.Status != "done" {
|
||||
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
|
||||
out = append(out, run)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func terminalAgentRunStatus(status string) bool {
|
||||
switch status {
|
||||
case "done", "canceled", "timeout", "rate_limited", "expired":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func agentRunFailureStatus(err error) string {
|
||||
switch ai.ClassifyError(err) {
|
||||
case ai.ErrorKindCanceled:
|
||||
return "canceled"
|
||||
case ai.ErrorKindTimeout:
|
||||
return "timeout"
|
||||
case ai.ErrorKindRateLimited:
|
||||
return "rate_limited"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return next(ctx, call)
|
||||
}
|
||||
name := toolCheckpointName(call)
|
||||
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
|
||||
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
|
||||
}
|
||||
|
||||
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
res := next(ctx, call)
|
||||
a.currentRun.Steps[idx].Attempts++
|
||||
if res.Refused != "" {
|
||||
a.currentRun.Steps[idx].Status = "failed"
|
||||
a.currentRun.Steps[idx].Error = res.Content
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
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)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func toolCheckpointName(call ai.ToolCall) string {
|
||||
b, _ := json.Marshal(call.Input)
|
||||
return "tool:" + call.Name + ":" + string(b)
|
||||
}
|
||||
|
||||
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
|
||||
for _, step := range steps {
|
||||
if step.Name == name {
|
||||
return step, true
|
||||
}
|
||||
}
|
||||
return flow.StepRecord{}, false
|
||||
}
|
||||
|
||||
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
|
||||
for i := range *steps {
|
||||
if (*steps)[i].Name == rec.Name {
|
||||
(*steps)[i].Status = rec.Status
|
||||
(*steps)[i].Error = rec.Error
|
||||
return i
|
||||
}
|
||||
}
|
||||
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
|
||||
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
|
||||
}
|
||||
*steps = append(*steps, rec)
|
||||
return len(*steps) - 1
|
||||
}
|
||||
|
||||
+324
-3
@@ -2,6 +2,8 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -11,7 +13,7 @@ import (
|
||||
|
||||
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "durable-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
@@ -48,9 +50,196 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-resume-agent")
|
||||
toolRuns := 0
|
||||
first := 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.charge", Input: map[string]any{"order": "42"}})
|
||||
if res.Content != "charged" {
|
||||
t.Fatalf("tool result = %q, want charged", res.Content)
|
||||
}
|
||||
}
|
||||
if first {
|
||||
first = false
|
||||
return nil, errors.New("model connection dropped after tool")
|
||||
}
|
||||
return &ai.Response{Reply: "finished from checkpoint"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("tool-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "charged", nil
|
||||
}))
|
||||
_, err := a.Ask(ctx, "charge order 42")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want simulated failure")
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
|
||||
}
|
||||
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending returned %d runs, want 1", len(runs))
|
||||
}
|
||||
resp, err := Resume(ctx, a, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resp.Reply != "finished from checkpoint" {
|
||||
t.Fatalf("Resume reply = %q", resp.Reply)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
toolRuns := 0
|
||||
modelCalls := 0
|
||||
failFirst := true
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
modelCalls++
|
||||
if opts.ToolHandler != nil {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
|
||||
if res.Content != "provisioned" {
|
||||
t.Fatalf("tool result = %q, want provisioned", res.Content)
|
||||
}
|
||||
}
|
||||
if failFirst {
|
||||
failFirst = false
|
||||
return nil, errors.New("process stopped after tool checkpoint")
|
||||
}
|
||||
return &ai.Response{Reply: "resumed after restart"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
newAgent := func() *agentImpl {
|
||||
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "provisioned", nil
|
||||
}))
|
||||
}
|
||||
|
||||
first := newAgent()
|
||||
_, err := first.Ask(ctx, "provision api")
|
||||
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)
|
||||
}
|
||||
runs, err := Pending(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending before restart: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
|
||||
}
|
||||
|
||||
restarted := newAgent()
|
||||
resp, err := Resume(ctx, restarted, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume after restart: %v", err)
|
||||
}
|
||||
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
|
||||
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
|
||||
}
|
||||
if modelCalls != 2 {
|
||||
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := store.NewMemoryStore()
|
||||
cp := flow.StoreCheckpoint(st, "memory-resume-agent")
|
||||
failRetry := true
|
||||
var sawRecall bool
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
for _, msg := range req.Messages {
|
||||
if text, ok := msg.Content.(string); ok && strings.Contains(text, "alpha code is 42") {
|
||||
sawRecall = true
|
||||
}
|
||||
}
|
||||
if strings.Contains(req.Prompt, "use alpha code") && failRetry {
|
||||
failRetry = false
|
||||
return nil, errors.New("model connection dropped")
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("memory-resume-agent"), WithStore(st), WithCheckpoint(cp), CompactMemory(4, 1), MemoryRecallLimit(2))
|
||||
for _, msg := range []string{"alpha code is 42", "beta note", "gamma note"} {
|
||||
if _, err := a.Ask(ctx, msg); err != nil {
|
||||
t.Fatalf("Ask(%q): %v", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Ask(ctx, "use alpha code now")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want simulated provider failure")
|
||||
}
|
||||
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
|
||||
t.Fatalf("failed Ask stored prompt %d times, want 1", got)
|
||||
}
|
||||
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending returned %d runs, want 1", len(runs))
|
||||
}
|
||||
if _, err := Resume(ctx, a, runs[0].ID); err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
|
||||
t.Fatalf("resumed failed Ask stored prompt %d times, want no duplicate", got)
|
||||
}
|
||||
if !sawRecall {
|
||||
t.Fatal("resume did not retrieve archived compacted memory")
|
||||
}
|
||||
if got := len(a.mem.Messages()); got > 4 {
|
||||
t.Fatalf("compacted memory retained %d messages after resume, want <= 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func countMemoryContent(messages []ai.Message, needle string) int {
|
||||
var count int
|
||||
for _, msg := range messages {
|
||||
if text, ok := msg.Content.(string); ok && strings.Contains(text, needle) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "pending-agent")
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
|
||||
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
@@ -65,9 +254,141 @@ func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingSkipsTerminalCanceledAndExpiredAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-agent")
|
||||
for _, run := range []flow.Run{
|
||||
{ID: "active", Flow: "terminal-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}},
|
||||
{ID: "done", Flow: "terminal-agent", Status: "done", State: flow.State{Stage: agentAskStep, Data: []byte("done")}},
|
||||
{ID: "canceled", Flow: "terminal-agent", Status: "canceled", State: flow.State{Stage: agentAskStep, Data: []byte("canceled")}},
|
||||
{ID: "expired", Flow: "terminal-agent", Status: "expired", State: flow.State{Stage: agentAskStep, Data: []byte("expired")}},
|
||||
} {
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save(%s): %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
a := newTestAgent(Name("terminal-agent"), WithCheckpoint(cp))
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ID != "active" {
|
||||
t.Fatalf("Pending = %#v, want only active failed run", runs)
|
||||
}
|
||||
for _, id := range []string{"canceled", "expired"} {
|
||||
if _, err := Resume(ctx, a, id); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||
t.Fatalf("Resume(%s) err = %v, want terminal status error", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
if opts.ToolHandler != nil {
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Which region should I deploy to?"}})
|
||||
}
|
||||
return &ai.Response{Reply: "waiting"}, nil
|
||||
}
|
||||
if !strings.Contains(req.Prompt, "Human input: us-east-1") {
|
||||
t.Fatalf("resumed prompt = %q, want human input", req.Prompt)
|
||||
}
|
||||
return &ai.Response{Reply: "deploying to us-east-1"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("input-agent"), WithCheckpoint(cp))
|
||||
_, err := a.Ask(ctx, "deploy the service")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want input-required pause")
|
||||
}
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].Status != "paused" || runs[0].State.Stage != agentInputStep {
|
||||
t.Fatalf("paused runs = %#v, want one input-required run", runs)
|
||||
}
|
||||
var pause inputPause
|
||||
if err := runs[0].State.Scan(&pause); err != nil {
|
||||
t.Fatalf("Scan pause: %v", err)
|
||||
}
|
||||
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Which region should I deploy to?" {
|
||||
t.Fatalf("pause = %#v", pause)
|
||||
}
|
||||
|
||||
if _, err := Resume(ctx, a, runs[0].ID); err == nil || !strings.Contains(err.Error(), "ResumeInput") {
|
||||
t.Fatalf("Resume input-required err = %v, want guidance", err)
|
||||
}
|
||||
resp, err := ResumeInput(ctx, a, runs[0].ID, "us-east-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResumeInput: %v", err)
|
||||
}
|
||||
if resp.RunID != runs[0].ID || resp.Reply != "deploying to us-east-1" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" {
|
||||
t.Fatalf("resumed run status = %q, want done", loaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanInputResumeHonorsCanceledContextAndLeavesRunPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-cancel-agent")
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler != nil {
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Approve deploy?"}})
|
||||
}
|
||||
return &ai.Response{Reply: "waiting"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("input-cancel-agent"), WithCheckpoint(cp))
|
||||
if _, err := a.Ask(ctx, "deploy the service"); err == nil {
|
||||
t.Fatal("Ask succeeded, want input-required pause")
|
||||
}
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if _, err := ResumeInput(canceled, a, runs[0].ID, "yes"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ResumeInput canceled err = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load paused run ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "paused" || loaded.State.Stage != agentInputStep {
|
||||
t.Fatalf("run status/stage after canceled resume = %s/%s, want paused/%s", loaded.Status, loaded.State.Stage, agentInputStep)
|
||||
}
|
||||
var pause inputPause
|
||||
if err := loaded.State.Scan(&pause); err != nil {
|
||||
t.Fatalf("Scan pause after canceled resume: %v", err)
|
||||
}
|
||||
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Approve deploy?" {
|
||||
t.Fatalf("pause after canceled resume = %#v", pause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "approval-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
|
||||
@@ -33,14 +33,29 @@ func TestAgentProviderConformanceMatrix(t *testing.T) {
|
||||
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
|
||||
}
|
||||
|
||||
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
|
||||
for _, provider := range providers {
|
||||
provider := provider
|
||||
if len(selected) > 0 && !selected[provider.name] {
|
||||
continue
|
||||
}
|
||||
t.Run(provider.name, func(t *testing.T) {
|
||||
runAgentConformanceScenario(t, provider)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func selectedConformanceProviders(csv string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out[part] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
t.Helper()
|
||||
if provider.live {
|
||||
@@ -156,3 +171,50 @@ func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
t.Fatalf("Ask error = %v, want conformance provider failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesProviderTextToolCallFallback(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")
|
||||
}
|
||||
return &ai.Response{
|
||||
Reply: `{"name":"conformance_echo","input":{"value":"agent-conformance"}}`,
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
a := New(
|
||||
Name("conformance-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 text tool call fallback.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("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)
|
||||
}
|
||||
if strings.Contains(resp.Reply, `"name":"conformance_echo"`) {
|
||||
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
+89
-17
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -23,6 +24,12 @@ type Memory interface {
|
||||
Clear()
|
||||
}
|
||||
|
||||
// MemorySummaryFunc turns older conversation messages into a compact
|
||||
// replacement message for active context. It is called while the default
|
||||
// memory is locked, so implementations should be deterministic and avoid
|
||||
// calling back into the same memory instance.
|
||||
type MemorySummaryFunc func([]ai.Message) ai.Message
|
||||
|
||||
// MemoryCompaction configures deterministic, store-backed context compaction
|
||||
// for the default memory implementation. When the retained conversation grows
|
||||
// past MaxMessages, older turns are collapsed into a summary message while the
|
||||
@@ -30,6 +37,7 @@ type Memory interface {
|
||||
type MemoryCompaction struct {
|
||||
MaxMessages int
|
||||
KeepRecent int
|
||||
Summarize MemorySummaryFunc
|
||||
}
|
||||
|
||||
// MemoryRecall is implemented by memory backends that can retrieve durable
|
||||
@@ -48,11 +56,29 @@ func NewMemory(s store.Store, key string, limit int) Memory {
|
||||
return m
|
||||
}
|
||||
|
||||
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
|
||||
// conversation and archives every turn for retrieval. It is useful when callers
|
||||
// want relevant durable recall without summary compaction in the active context.
|
||||
// A nil store or empty key keeps only the active in-process buffer.
|
||||
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
|
||||
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
|
||||
m.load()
|
||||
return m
|
||||
}
|
||||
|
||||
// NewCompactingMemory returns store-backed memory with explicit compaction and
|
||||
// retrieval controls. It keeps all messages in the backing store, compacts older
|
||||
// turns into a deterministic summary when the conversation exceeds maxMessages,
|
||||
// and lets callers recall relevant prior turns with Recall.
|
||||
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
|
||||
return NewCompactingMemoryWithOptions(s, key, MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent})
|
||||
}
|
||||
|
||||
// NewCompactingMemoryWithOptions returns store-backed memory configured with
|
||||
// explicit compaction options, including an optional summarization hook.
|
||||
func NewCompactingMemoryWithOptions(s store.Store, key string, compaction MemoryCompaction) Memory {
|
||||
maxMessages := compaction.MaxMessages
|
||||
keepRecent := compaction.KeepRecent
|
||||
if keepRecent <= 0 {
|
||||
keepRecent = maxMessages / 2
|
||||
}
|
||||
@@ -68,6 +94,7 @@ func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int)
|
||||
compaction: MemoryCompaction{
|
||||
MaxMessages: maxMessages,
|
||||
KeepRecent: keepRecent,
|
||||
Summarize: compaction.Summarize,
|
||||
},
|
||||
}
|
||||
m.load()
|
||||
@@ -83,16 +110,20 @@ func NewInMemory(limit int) Memory {
|
||||
// storeMemory is the default Memory: an ai.History buffer optionally
|
||||
// persisted to a store.
|
||||
type storeMemory struct {
|
||||
mu sync.Mutex
|
||||
store store.Store
|
||||
key string
|
||||
hist *ai.History
|
||||
compaction MemoryCompaction
|
||||
archive []ai.Message
|
||||
mu sync.Mutex
|
||||
store store.Store
|
||||
key string
|
||||
hist *ai.History
|
||||
compaction MemoryCompaction
|
||||
archive []ai.Message
|
||||
retrieveAll bool
|
||||
}
|
||||
|
||||
func (m *storeMemory) Add(role, content string) {
|
||||
m.mu.Lock()
|
||||
if m.retrieveAll {
|
||||
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
|
||||
}
|
||||
m.hist.Add(role, content)
|
||||
m.mu.Unlock()
|
||||
m.compact()
|
||||
@@ -116,6 +147,8 @@ func (m *storeMemory) Clear() {
|
||||
// Recall returns archived messages whose content contains words from query.
|
||||
// It is deterministic and provider-neutral: no embeddings or model calls are
|
||||
// required, but semantic/vector stores can replace Memory for richer retrieval.
|
||||
// When created with NewRetrievalMemory the archive contains every persisted
|
||||
// turn; when created with NewCompactingMemory it contains compacted older turns.
|
||||
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -123,17 +156,31 @@ func (m *storeMemory) Recall(query string, limit int) []ai.Message {
|
||||
limit = 5
|
||||
}
|
||||
terms := recallTerms(query)
|
||||
var out []ai.Message
|
||||
for i := len(m.archive) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
type match struct {
|
||||
msg ai.Message
|
||||
score int
|
||||
index int
|
||||
}
|
||||
matches := make([]match, 0, len(m.archive))
|
||||
for i := len(m.archive) - 1; i >= 0; i-- {
|
||||
msg := m.archive[i]
|
||||
text := strings.ToLower(fmt.Sprint(msg.Content))
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, term) {
|
||||
out = append(out, msg)
|
||||
break
|
||||
}
|
||||
if score := recallScore(msg, terms); score > 0 {
|
||||
matches = append(matches, match{msg: msg, score: score, index: i})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score > matches[j].score
|
||||
}
|
||||
return matches[i].index > matches[j].index
|
||||
})
|
||||
if len(matches) > limit {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
out := make([]ai.Message, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
out = append(out, match.msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -155,6 +202,9 @@ func (m *storeMemory) load() {
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.archive = state.Archive
|
||||
if m.retrieveAll && len(m.archive) == 0 {
|
||||
m.archive = append(m.archive, state.Messages...)
|
||||
}
|
||||
for _, msg := range state.Messages {
|
||||
m.hist.Add(msg.Role, msg.Content)
|
||||
}
|
||||
@@ -198,9 +248,13 @@ func (m *storeMemory) compact() {
|
||||
older := msgs[:cut]
|
||||
recent := msgs[cut:]
|
||||
m.archive = append(m.archive, older...)
|
||||
summary := ai.Message{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
|
||||
summarize := m.compaction.Summarize
|
||||
if summarize == nil {
|
||||
summarize = defaultMemorySummary
|
||||
}
|
||||
summary := summarize(older)
|
||||
if summary.Role == "" {
|
||||
summary.Role = "system"
|
||||
}
|
||||
m.hist.Reset()
|
||||
m.hist.Add(summary.Role, summary.Content)
|
||||
@@ -209,6 +263,13 @@ func (m *storeMemory) compact() {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMemorySummary(msgs []ai.Message) ai.Message {
|
||||
return ai.Message{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(msgs)),
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeMessages(msgs []ai.Message) string {
|
||||
var b strings.Builder
|
||||
for i, msg := range msgs {
|
||||
@@ -228,6 +289,17 @@ func compactText(s string, max int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func recallScore(msg ai.Message, terms []string) int {
|
||||
text := strings.ToLower(fmt.Sprint(msg.Content))
|
||||
score := 0
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, term) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func recallTerms(query string) []string {
|
||||
seen := map[string]bool{}
|
||||
var terms []string
|
||||
|
||||
@@ -3,9 +3,11 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -62,6 +64,119 @@ func TestWithMemoryUsed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta owner is lee")
|
||||
m.Add("assistant", "tracked")
|
||||
m.Add("user", "alpha owner is sam")
|
||||
|
||||
if got := len(m.Messages()); got != 2 {
|
||||
t.Fatalf("active messages = %d, want bounded history of 2", got)
|
||||
}
|
||||
|
||||
recall, ok := m.(MemoryRecall)
|
||||
if !ok {
|
||||
t.Fatal("retrieval memory should support recall")
|
||||
}
|
||||
recalled := recall.Recall("alpha budget", 2)
|
||||
if len(recalled) == 0 {
|
||||
t.Fatal("expected relevant recalled turns")
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
|
||||
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
|
||||
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
t.Fatalf("recalled %d messages, want 1", len(recalled))
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded recall = %q, want alpha budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
|
||||
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
|
||||
writer := m.(Memory)
|
||||
writer.Add("user", "alpha budget is 42")
|
||||
writer.Add("assistant", "noted")
|
||||
writer.Add("user", "beta budget is 7")
|
||||
writer.Add("assistant", "noted")
|
||||
writer.Add("user", "alpha owner is sam")
|
||||
|
||||
recalled := m.Recall("alpha budget", 2)
|
||||
if len(recalled) == 0 {
|
||||
t.Fatal("expected recalled messages")
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("top recall = %q, want alpha budget match", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewCompactingMemory(st, "agent/reload/history", 3, 1)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
m.Add("assistant", "noted")
|
||||
|
||||
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
|
||||
recall, ok := reloaded.(MemoryRecall)
|
||||
if !ok {
|
||||
t.Fatal("compacting memory should support recall")
|
||||
}
|
||||
recalled := recall.Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
t.Fatalf("recalled %d messages, want 1", len(recalled))
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded recall = %q, want alpha budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{
|
||||
MaxMessages: 3,
|
||||
KeepRecent: 1,
|
||||
Summarize: func(msgs []ai.Message) ai.Message {
|
||||
return ai.Message{Role: "system", Content: "custom summary count=" + strconv.Itoa(len(msgs))}
|
||||
},
|
||||
})
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
m.Add("assistant", "noted")
|
||||
|
||||
msgs := m.Messages()
|
||||
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
|
||||
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
|
||||
}
|
||||
|
||||
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
|
||||
recall := reloaded.(MemoryRecall)
|
||||
recalled := recall.Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
t.Fatalf("recalled %d messages, want 1", len(recalled))
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded recall = %q, want alpha budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom tool is offered to the model and dispatched to its handler.
|
||||
func TestWithToolExposedAndDispatched(t *testing.T) {
|
||||
var got map[string]any
|
||||
|
||||
+69
-1
@@ -56,10 +56,23 @@ type Options struct {
|
||||
// ModelRetryBackoff is the base delay between transient provider failures
|
||||
// (grows exponentially per attempt when retries are enabled).
|
||||
ModelRetryBackoff time.Duration
|
||||
// ToolTimeout bounds each tool execution (0 disables). The timeout is
|
||||
// applied before custom tools, delegate, and service RPC calls so context
|
||||
// deadlines propagate consistently through the agent loop.
|
||||
ToolTimeout time.Duration
|
||||
// ToolMaxAttempts bounds tool execution attempts including the first call.
|
||||
// Default 1; retries are opt-in because tools can have side effects.
|
||||
ToolMaxAttempts int
|
||||
// ToolRetryBackoff is the base delay between transient tool failures.
|
||||
ToolRetryBackoff time.Duration
|
||||
|
||||
// Memory is the agent's conversation memory. Nil = the default
|
||||
// store-backed memory (durable across restarts).
|
||||
Memory Memory
|
||||
// MemoryRetrievalLimit enables retrieval-backed default memory without
|
||||
// compaction. The active conversation stays bounded to this many messages
|
||||
// while every turn is archived for deterministic recall.
|
||||
MemoryRetrievalLimit int
|
||||
// MemoryCompaction enables deterministic compaction/retrieval on the
|
||||
// default store-backed memory. Custom Memory implementations can expose
|
||||
// retrieval by implementing MemoryRecall.
|
||||
@@ -91,6 +104,11 @@ type Options struct {
|
||||
// and tool calls. Nil disables instrumentation.
|
||||
TraceProvider trace.TracerProvider
|
||||
|
||||
// TraceInputs controls whether agent observability records include raw
|
||||
// user messages. It is false by default so spans and persisted run
|
||||
// timelines carry correlation and shape without leaking prompts.
|
||||
TraceInputs bool
|
||||
|
||||
// tools are developer-registered custom tools (see WithTool).
|
||||
tools []customTool
|
||||
// wrappers are developer-registered tool-execution wrappers
|
||||
@@ -107,6 +125,9 @@ func newOptions(opts ...Option) Options {
|
||||
ModelTimeout: 30 * time.Second,
|
||||
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
|
||||
ModelRetryBackoff: 100 * time.Millisecond,
|
||||
ToolTimeout: 30 * time.Second,
|
||||
ToolMaxAttempts: 1,
|
||||
ToolRetryBackoff: 100 * time.Millisecond,
|
||||
// On by default and lenient: identical repeated calls are a
|
||||
// no-progress loop, never useful. Set LoopLimit(0) to disable.
|
||||
LoopLimit: 3,
|
||||
@@ -199,6 +220,14 @@ func ModelCallTimeout(d time.Duration) Option {
|
||||
return func(o *Options) { o.ModelTimeout = d }
|
||||
}
|
||||
|
||||
// ToolCallTimeout sets the timeout for each tool execution. It bounds custom
|
||||
// tools, built-in delegate calls, and service RPC tools with the same context
|
||||
// deadline so mid-run cancellation and slow tools produce safe error results
|
||||
// instead of unbounded agent runs. Set 0 to disable.
|
||||
func ToolCallTimeout(d time.Duration) Option {
|
||||
return func(o *Options) { o.ToolTimeout = d }
|
||||
}
|
||||
|
||||
// ModelRetry sets the provider retry budget and backoff for transient failures.
|
||||
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
@@ -207,6 +236,16 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// ToolRetry sets the tool retry budget and backoff for transient failures.
|
||||
// Attempts include the first call. Retries are opt-in because tools may have
|
||||
// side effects; keep handlers idempotent before enabling this.
|
||||
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolMaxAttempts = maxAttempts
|
||||
o.ToolRetryBackoff = backoff
|
||||
}
|
||||
}
|
||||
|
||||
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
|
||||
// ":4000"), so other agents can reach it directly by URL without a
|
||||
// separate gateway. The agent stays a normal go-micro service as well;
|
||||
@@ -222,19 +261,40 @@ func WithMemory(m Memory) Option {
|
||||
return func(o *Options) { o.Memory = m }
|
||||
}
|
||||
|
||||
// RetrievalMemory enables deterministic, store-backed retrieval memory for
|
||||
// the default agent memory without compaction. Active context is capped at
|
||||
// activeLimit messages while every turn is archived in the store for Recall.
|
||||
func RetrievalMemory(activeLimit int) Option {
|
||||
return func(o *Options) {
|
||||
o.MemoryRetrievalLimit = activeLimit
|
||||
if o.MemoryRecallLimit == 0 {
|
||||
o.MemoryRecallLimit = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CompactMemory enables deterministic, store-backed memory compaction for the
|
||||
// default agent memory. Older turns are summarized once active context exceeds
|
||||
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
|
||||
// turns are injected into matching future asks.
|
||||
func CompactMemory(maxMessages, keepRecent int) Option {
|
||||
return func(o *Options) {
|
||||
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent}
|
||||
o.MemoryCompaction.MaxMessages = maxMessages
|
||||
o.MemoryCompaction.KeepRecent = keepRecent
|
||||
if o.MemoryRecallLimit == 0 {
|
||||
o.MemoryRecallLimit = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MemorySummarizer sets the deterministic summarization hook used by the
|
||||
// default compacting memory. It is optional; without it, compacted memory uses
|
||||
// a provider-neutral text summary. The hook receives the older messages being
|
||||
// removed from active context and returns the replacement summary message.
|
||||
func MemorySummarizer(fn MemorySummaryFunc) Option {
|
||||
return func(o *Options) { o.MemoryCompaction.Summarize = fn }
|
||||
}
|
||||
|
||||
// MemoryRecallLimit sets how many archived turns a memory backend may inject
|
||||
// into a model request for the current Ask. Use 0 to disable retrieval.
|
||||
func MemoryRecallLimit(n int) Option {
|
||||
@@ -295,3 +355,11 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
|
||||
// TraceInputs opts in to recording raw user messages on agent run events.
|
||||
// By default inputs are redacted from OpenTelemetry spans and persisted run
|
||||
// timelines; use this only when the observability backend is approved to store
|
||||
// prompt content.
|
||||
func TraceInputs(enabled bool) Option {
|
||||
return func(o *Options) { o.TraceInputs = enabled }
|
||||
}
|
||||
|
||||
+205
-65
@@ -22,36 +22,51 @@ const (
|
||||
spanNameModelCall = "agent.model.call"
|
||||
spanNameToolCall = "agent.tool.call"
|
||||
|
||||
AttrRunID = "agent.run.id"
|
||||
AttrParentRunID = "agent.run.parent_id"
|
||||
AttrAgentName = "agent.name"
|
||||
AttrProvider = "agent.model.provider"
|
||||
AttrModel = "agent.model.name"
|
||||
AttrLatencyMS = "agent.latency_ms"
|
||||
AttrInputTokens = "agent.tokens.input"
|
||||
AttrOutputTokens = "agent.tokens.output"
|
||||
AttrTotalTokens = "agent.tokens.total"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
AttrRefusal = "agent.refusal"
|
||||
AttrRunID = "agent.run.id"
|
||||
AttrParentRunID = "agent.run.parent_id"
|
||||
AttrAgentName = "agent.name"
|
||||
AttrProvider = "agent.model.provider"
|
||||
AttrModel = "agent.model.name"
|
||||
AttrLatencyMS = "agent.latency_ms"
|
||||
AttrInputTokens = "agent.tokens.input"
|
||||
AttrOutputTokens = "agent.tokens.output"
|
||||
AttrTotalTokens = "agent.tokens.total"
|
||||
AttrAttempt = "agent.model.attempt"
|
||||
AttrMaxAttempts = "agent.model.max_attempts"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
AttrRefusal = "agent.refusal"
|
||||
AttrInputChars = "agent.input.chars"
|
||||
AttrErrorKind = "agent.error.kind"
|
||||
AttrCheckpointStatus = "agent.checkpoint.status"
|
||||
AttrCheckpointStage = "agent.checkpoint.stage"
|
||||
AttrFlowName = "agent.flow.name"
|
||||
AttrFlowStep = "agent.flow.step"
|
||||
AttrDispatch = "agent.dispatch"
|
||||
AttrTrigger = "agent.trigger"
|
||||
)
|
||||
|
||||
type RunEvent struct {
|
||||
Time time.Time `json:"time"`
|
||||
RunID string `json:"run_id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Tokens Usage `json:"tokens,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
RunID string `json:"run_id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
MaxAttempts int `json:"max_attempts,omitempty"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Tokens Usage `json:"tokens,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorKind string `json:"error_kind,omitempty"`
|
||||
InputChars int `json:"input_chars,omitempty"`
|
||||
}
|
||||
|
||||
type Usage = ai.Usage
|
||||
@@ -60,7 +75,8 @@ type Usage = ai.Usage
|
||||
// Zero values preserve the full deterministic run list.
|
||||
type RunListOptions struct {
|
||||
// Status, when set, keeps only runs with the matching status
|
||||
// (for example "running", "done", "error", or "refused").
|
||||
// (for example "running", "done", "canceled", "timeout",
|
||||
// "rate_limited", "error", or "refused").
|
||||
Status string
|
||||
// TraceID, when set, keeps only runs correlated with this trace id.
|
||||
// A prefix is accepted so operators can paste the shortened trace id
|
||||
@@ -73,18 +89,19 @@ type RunListOptions struct {
|
||||
|
||||
// RunSummary is a compact index entry for a recorded agent run.
|
||||
type RunSummary struct {
|
||||
RunID string `json:"run_id"`
|
||||
Agent string `json:"agent"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
Events int `json:"events"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
RunID string `json:"run_id"`
|
||||
Agent string `json:"agent"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
Events int `json:"events"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorKind string `json:"last_error_kind,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) tracer() trace.Tracer {
|
||||
@@ -94,29 +111,38 @@ func (a *agentImpl) tracer() trace.Tracer {
|
||||
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, func(error)) {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
start := time.Now()
|
||||
runEvent := RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", InputChars: len(message)}
|
||||
if a.opts.TraceInputs {
|
||||
runEvent.Name = message
|
||||
}
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
a.recordRunEvent(runEvent)
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
|
||||
return
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
|
||||
}
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
|
||||
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
attrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
}, info)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
|
||||
a.recordSpanEvent(span, runEvent)
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
|
||||
if err != nil {
|
||||
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
|
||||
@@ -144,24 +170,32 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
}
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
|
||||
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.recordRunEvent(e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
|
||||
attrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
attribute.String(AttrProvider, provider),
|
||||
attribute.String(AttrModel, model),
|
||||
))
|
||||
}, info)
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
if info.Attempt > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
|
||||
}
|
||||
if info.MaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, info.MaxAttempts))
|
||||
}
|
||||
usage := ai.Usage{}
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
@@ -169,15 +203,17 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
if err != nil {
|
||||
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} 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, LatencyMS: dur, Tokens: usage}
|
||||
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)
|
||||
return resp, err
|
||||
@@ -204,7 +240,8 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if a.opts.TraceProvider == nil {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
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: resultError(res)})
|
||||
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)})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -221,8 +258,11 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if res.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
resErr := resultError(res)
|
||||
if kind := classifyToolError(resErr); kind != "" {
|
||||
attrs = append(attrs, attribute.String(AttrErrorKind, kind))
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
if res.Refused != "" {
|
||||
span.SetStatus(codes.Error, res.Refused)
|
||||
} else if resErr != "" {
|
||||
@@ -231,7 +271,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
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})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -248,14 +288,103 @@ func resultError(res ai.ToolResult) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func classifyToolError(err string) string {
|
||||
switch {
|
||||
case err == "":
|
||||
return ""
|
||||
case strings.Contains(strings.ToLower(err), "context canceled"):
|
||||
return string(ai.ErrorKindCanceled)
|
||||
case strings.Contains(strings.ToLower(err), "deadline exceeded"):
|
||||
return string(ai.ErrorKindTimeout)
|
||||
default:
|
||||
return string(ai.ErrorKindProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordTimelineEvent(ctx context.Context, e RunEvent) {
|
||||
span := trace.SpanFromContext(ctx)
|
||||
if span.SpanContext().IsValid() {
|
||||
a.recordSpanEvent(span, e)
|
||||
return
|
||||
}
|
||||
a.recordRunEvent(e)
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
|
||||
if sc := span.SpanContext(); sc.IsValid() {
|
||||
e.TraceID = sc.TraceID().String()
|
||||
e.SpanID = sc.SpanID().String()
|
||||
}
|
||||
span.AddEvent("agent."+e.Kind, trace.WithTimestamp(e.Time), trace.WithAttributes(runEventAttributes(e)...))
|
||||
a.recordRunEvent(e)
|
||||
}
|
||||
|
||||
func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrRunID, e.RunID),
|
||||
attribute.String(AttrAgentName, e.Agent),
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
|
||||
}
|
||||
if e.Name != "" {
|
||||
attrs = append(attrs, attribute.String("agent.event.name", e.Name))
|
||||
}
|
||||
if e.Provider != "" {
|
||||
attrs = append(attrs, attribute.String(AttrProvider, e.Provider))
|
||||
}
|
||||
if e.Model != "" {
|
||||
attrs = append(attrs, attribute.String(AttrModel, e.Model))
|
||||
}
|
||||
if e.Attempt > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
}
|
||||
if e.MaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
|
||||
}
|
||||
if e.InputChars > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
|
||||
}
|
||||
attrs = appendUsage(attrs, e.Tokens)
|
||||
if e.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
|
||||
}
|
||||
if e.Error != "" {
|
||||
attrs = append(attrs, attribute.String("agent.error", e.Error))
|
||||
}
|
||||
if e.ErrorKind != "" {
|
||||
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
|
||||
}
|
||||
if e.Kind == "checkpoint" {
|
||||
if e.Status != "" {
|
||||
attrs = append(attrs, attribute.String(AttrCheckpointStatus, e.Status))
|
||||
}
|
||||
if e.Name != "" {
|
||||
attrs = append(attrs, attribute.String(AttrCheckpointStage, e.Name))
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
|
||||
if info.Flow != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
|
||||
}
|
||||
if info.Step != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
|
||||
}
|
||||
if info.Dispatch != "" {
|
||||
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
|
||||
}
|
||||
if info.Trigger != "" {
|
||||
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if e.RunID == "" {
|
||||
return
|
||||
@@ -332,6 +461,9 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
|
||||
if e.Error != "" {
|
||||
summary.LastError = e.Error
|
||||
}
|
||||
if e.ErrorKind != "" {
|
||||
summary.LastErrorKind = e.ErrorKind
|
||||
}
|
||||
}
|
||||
if opts.Status != "" && summary.Status != opts.Status {
|
||||
continue
|
||||
@@ -358,24 +490,32 @@ func runStatus(events []RunEvent) string {
|
||||
}
|
||||
status := "running"
|
||||
for _, e := range events {
|
||||
if e.Error != "" {
|
||||
status = "error"
|
||||
}
|
||||
if e.Refused != "" && status != "error" {
|
||||
if e.Refused != "" && status == "running" {
|
||||
status = "refused"
|
||||
}
|
||||
switch e.Kind {
|
||||
case "error":
|
||||
status = "error"
|
||||
case "done":
|
||||
if status == "running" {
|
||||
status = "done"
|
||||
}
|
||||
if e.Error != "" || e.Kind == "error" {
|
||||
status = runErrorStatus(e.ErrorKind)
|
||||
}
|
||||
if e.Kind == "done" && status == "running" {
|
||||
status = "done"
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func runErrorStatus(kind string) string {
|
||||
switch ai.ErrorKind(kind) {
|
||||
case ai.ErrorKindCanceled:
|
||||
return "canceled"
|
||||
case ai.ErrorKindTimeout:
|
||||
return "timeout"
|
||||
case ai.ErrorKindRateLimited:
|
||||
return "rate_limited"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
|
||||
st := store.Scope(s, "agent", agentName)
|
||||
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
|
||||
|
||||
+268
-5
@@ -3,18 +3,23 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
const codesError = codes.Error
|
||||
|
||||
type otelTestModel struct{ opts ai.Options }
|
||||
|
||||
func (m *otelTestModel) Init(opts ...ai.Option) error {
|
||||
@@ -71,6 +76,16 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if runID == "" {
|
||||
t.Fatal("run span missing run id attribute")
|
||||
}
|
||||
var runEvents []trace.Event
|
||||
for _, s := range spans {
|
||||
if s.Name() == spanNameRun {
|
||||
runEvents = s.Events()
|
||||
break
|
||||
}
|
||||
}
|
||||
if !spanEventHasRunInfo(runEvents, "agent.run", runID, "runner") || !spanEventHasRunInfo(runEvents, "agent.done", runID, "runner") {
|
||||
t.Fatalf("run span missing run-info events: %#v", runEvents)
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
|
||||
continue
|
||||
@@ -79,6 +94,9 @@ 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)
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
@@ -115,6 +133,159 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
|
||||
secret := "deploy production with token sk-secret"
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("redactor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
|
||||
if _, err := a.Ask(context.Background(), secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
var sawInputChars bool
|
||||
for _, s := range spans {
|
||||
for _, event := range s.Events() {
|
||||
attrs := spanAttributes(event.Attributes)
|
||||
if attrs["agent.event.name"] == secret {
|
||||
t.Fatalf("span event leaked raw input: %#v", event)
|
||||
}
|
||||
if attrs[AttrInputChars] == fmt.Sprint(len(secret)) {
|
||||
sawInputChars = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawInputChars {
|
||||
t.Fatal("run event missing redacted input length attribute")
|
||||
}
|
||||
|
||||
summaries, err := ListRunSummaries(st, "redactor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "redactor", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Name == secret {
|
||||
t.Fatalf("persisted run event leaked raw input: %#v", event)
|
||||
}
|
||||
if event.Kind == "run" && event.InputChars != len(secret) {
|
||||
t.Fatalf("run event InputChars = %d, want %d", event.InputChars, len(secret))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceInputsOptInRecordsInput(t *testing.T) {
|
||||
message := "operator-approved diagnostic prompt"
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("input-opt-in"), Provider("oteltest"), WithStore(st), TraceInputs(true))
|
||||
if _, err := a.Ask(context.Background(), message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
summaries, err := ListRunSummaries(st, "input-opt-in")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "input-opt-in", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Kind == "run" && event.Name == message {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("opt-in run event did not record message: %#v", events)
|
||||
}
|
||||
|
||||
type failingOtelModel struct{ opts ai.Options }
|
||||
|
||||
func (m *failingOtelModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *failingOtelModel) Options() ai.Options { return m.opts }
|
||||
func (m *failingOtelModel) String() string { return "otelfail" }
|
||||
func (m *failingOtelModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *failingOtelModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
|
||||
return nil, errors.New("provider exploded")
|
||||
}
|
||||
|
||||
func init() {
|
||||
ai.Register("otelfail", func(opts ...ai.Option) ai.Model { return &failingOtelModel{opts: ai.NewOptions(opts...)} })
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetrySpansModelFailure(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("failing-runner"), Provider("otelfail"), WithStore(st), TraceProvider(tp))
|
||||
if _, err := a.Ask(context.Background(), "hello"); err == nil {
|
||||
t.Fatal("Ask succeeded, want provider error")
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
var sawRunError, sawModelError bool
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
switch s.Name() {
|
||||
case spanNameRun:
|
||||
if attrs[AttrAgentName] == "failing-runner" && s.Status().Code == codesError {
|
||||
sawRunError = true
|
||||
}
|
||||
case spanNameModelCall:
|
||||
if attrs[AttrAgentName] == "failing-runner" && attrs[AttrAttempt] == "1" && attrs[AttrErrorKind] == string(ai.ErrorKindUnknown) && s.Status().Code == codesError {
|
||||
sawModelError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRunError || !sawModelError {
|
||||
t.Fatalf("missing error spans: run=%v model=%v spans=%d", sawRunError, sawModelError, len(spans))
|
||||
}
|
||||
|
||||
summaries, err := ListRunSummaries(st, "failing-runner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 1 || summaries[0].Status != "error" || summaries[0].LastError == "" {
|
||||
t.Fatalf("unexpected failure summary: %#v", summaries)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "failing-runner", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawModelEvent bool
|
||||
for _, event := range events {
|
||||
if event.Kind == "model" && event.Attempt == 1 && event.MaxAttempts == 1 && event.Error != "" && event.ErrorKind == string(ai.ErrorKindUnknown) {
|
||||
sawModelEvent = true
|
||||
}
|
||||
}
|
||||
if !sawModelEvent {
|
||||
t.Fatalf("missing failed model event with attempt metadata: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
|
||||
for _, event := range events {
|
||||
if event.Name != name {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(event.Attributes)
|
||||
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
@@ -218,6 +389,74 @@ func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCheckpointAndResumeTimelineEvents(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
cp := flow.StoreCheckpoint(st, "resume-otel-agent")
|
||||
first := true
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if first {
|
||||
first = false
|
||||
return nil, errors.New("temporary provider failure")
|
||||
}
|
||||
return &ai.Response{Reply: "resumed"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("resume-otel-agent"), WithStore(st), WithCheckpoint(cp), TraceProvider(tp))
|
||||
_, err := a.Ask(context.Background(), "resume me")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want simulated failure")
|
||||
}
|
||||
|
||||
runs, err := cp.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
|
||||
}
|
||||
resp, err := Resume(context.Background(), a, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resp.Reply != "resumed" {
|
||||
t.Fatalf("reply = %q, want resumed", resp.Reply)
|
||||
}
|
||||
|
||||
events, err := LoadRunEvents(st, "resume-otel-agent", runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{"checkpoint": false, "resume": false}
|
||||
for _, e := range events {
|
||||
if _, ok := seen[e.Kind]; ok {
|
||||
seen[e.Kind] = true
|
||||
}
|
||||
}
|
||||
for kind, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("missing %s event in timeline: %#v", kind, events)
|
||||
}
|
||||
}
|
||||
|
||||
var resumeSpanEvent bool
|
||||
for _, s := range exp.GetSpans().Snapshots() {
|
||||
if s.Name() != spanNameRun {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Events() {
|
||||
if e.Name == "agent.resume" {
|
||||
resumeSpanEvent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resumeSpanEvent {
|
||||
t.Fatal("run span missing agent.resume event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRunEventsSortsTimelineKeys(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
@@ -259,7 +498,7 @@ func TestListRunSummaries(t *testing.T) {
|
||||
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
@@ -282,11 +521,35 @@ func TestListRunSummaries(t *testing.T) {
|
||||
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
|
||||
t.Fatalf("unexpected run-a summary: %#v", got[0])
|
||||
}
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "error" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "boom" {
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
|
||||
t.Fatalf("unexpected run-b summary: %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind ai.ErrorKind
|
||||
want string
|
||||
}{
|
||||
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
|
||||
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
|
||||
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
|
||||
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := runStatus([]RunEvent{
|
||||
{Kind: "run"},
|
||||
{Kind: "error", Error: "failed", ErrorKind: string(tt.kind)},
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("runStatus() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
@@ -294,7 +557,7 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
|
||||
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "rate limit exceeded", ErrorKind: string(ai.ErrorKindRateLimited)},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
@@ -306,11 +569,11 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "error", TraceID: "abcdef", Limit: 1})
|
||||
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "rate_limited", TraceID: "abcdef", Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "error" {
|
||||
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "rate_limited" {
|
||||
t.Fatalf("filtered summaries = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestAskCancellationAbortsPromptly(t *testing.T) {
|
||||
@@ -102,3 +104,128 @@ func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
|
||||
t.Fatalf("plan persisted after canceled tool context: %q", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
|
||||
var sawDeadline bool
|
||||
a := newTestAgent(
|
||||
Name("tool-timeout"),
|
||||
ToolCallTimeout(10*time.Millisecond),
|
||||
WithTool("slow", "slow tool", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
sawDeadline = true
|
||||
}
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
}),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
content := toolContent(a.toolHandler(), "slow", nil)
|
||||
if !sawDeadline {
|
||||
t.Fatal("custom tool did not receive a deadline")
|
||||
}
|
||||
if !strings.Contains(content, context.DeadlineExceeded.Error()) {
|
||||
t.Fatalf("tool result = %q, want deadline exceeded", content)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
|
||||
t.Fatalf("tool call took %s, want bounded timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "canceled", err: context.Canceled, want: "canceled"},
|
||||
{name: "timeout", err: context.DeadlineExceeded, want: "timeout"},
|
||||
{name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-"))
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, tt.err
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp))
|
||||
_, err := a.Ask(context.Background(), "fail safely")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want failure")
|
||||
}
|
||||
|
||||
runs, err := cp.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
|
||||
}
|
||||
if runs[0].Status != tt.want {
|
||||
t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want)
|
||||
}
|
||||
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
|
||||
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
|
||||
}
|
||||
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
|
||||
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testStatusError struct {
|
||||
code int
|
||||
}
|
||||
|
||||
func (e testStatusError) Error() string { return "provider status error" }
|
||||
|
||||
func (e testStatusError) StatusCode() int { return e.code }
|
||||
|
||||
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
a := newTestAgent(
|
||||
Name("tool-retry-success"),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
|
||||
content := toolContent(a.toolHandler(), "flaky", nil)
|
||||
if content != "ok" {
|
||||
t.Fatalf("tool result = %q, want ok", content)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
|
||||
attempts := 0
|
||||
a := newTestAgent(
|
||||
Name("tool-retry-refusal"),
|
||||
MaxSteps(1),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
|
||||
attempts++
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
h := a.toolHandler()
|
||||
_ = toolContent(h, "counted", nil)
|
||||
content := toolContent(h, "counted", nil)
|
||||
if !strings.Contains(content, "step limit reached") {
|
||||
t.Fatalf("tool result = %q, want step-limit refusal", content)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// StreamEventType identifies an event emitted by a tool-aware agent stream.
|
||||
type StreamEventType string
|
||||
|
||||
const (
|
||||
// StreamEventToolStart is emitted immediately before a tool call runs.
|
||||
StreamEventToolStart StreamEventType = "tool_start"
|
||||
// StreamEventToolEnd is emitted after a tool call returns or is refused.
|
||||
StreamEventToolEnd StreamEventType = "tool_end"
|
||||
// StreamEventToken carries a chunk of the final answer.
|
||||
StreamEventToken StreamEventType = "token"
|
||||
// StreamEventDone carries the completed agent response.
|
||||
StreamEventDone StreamEventType = "done"
|
||||
)
|
||||
|
||||
// StreamEvent is one event from StreamAsk.
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType
|
||||
Token string
|
||||
ToolCall ai.ToolCall
|
||||
Result ai.ToolResult
|
||||
Response *Response
|
||||
}
|
||||
|
||||
// AgentStream is a stream of tool execution events followed by final-answer chunks.
|
||||
type AgentStream interface {
|
||||
Recv() (*StreamEvent, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// StreamAsk runs an agent Ask turn with tool start/end events and streams the final answer.
|
||||
// It is additive for callers that hold the public Agent interface; concrete agents also
|
||||
// expose the same method directly.
|
||||
func StreamAsk(ctx context.Context, ag Agent, message string) (AgentStream, error) {
|
||||
streamer, ok := ag.(interface {
|
||||
StreamAsk(context.Context, string) (AgentStream, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("agent: StreamAsk unsupported by implementation")
|
||||
}
|
||||
return streamer.StreamAsk(ctx, message)
|
||||
}
|
||||
|
||||
// ResumeStreamAsk resumes a checkpointed agent run and emits the same event
|
||||
// shape as StreamAsk. Completed runs are streamed from the persisted response;
|
||||
// unfinished runs continue from their checkpoint and emit tool events for any
|
||||
// work that still needs to run. Tool calls already recorded as done in the
|
||||
// checkpoint are reused by the agent checkpoint wrapper and are not re-executed.
|
||||
func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, errors.New("agent: ResumeStreamAsk unsupported by implementation")
|
||||
}
|
||||
return a.resumeStreamAsk(ctx, runID)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.askWithStreamEvents(ctx, message, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
}
|
||||
for _, tok := range splitStreamTokens(resp.Reply) {
|
||||
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
}
|
||||
for _, tok := range splitStreamTokens(resp.Reply) {
|
||||
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) askWithStreamEvents(ctx context.Context, message string, events chan<- *StreamEvent) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.tools == nil {
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
}
|
||||
base := a.toolHandler()
|
||||
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
|
||||
result := base(ctx, call)
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
|
||||
return result
|
||||
}
|
||||
a.setupWithToolHandler(handler)
|
||||
defer a.setupWithToolHandler(nil)
|
||||
return a.askLocked(ctx, uuid.New().String(), message, a.parentRunID, nil, true)
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeWithStreamEvents(ctx context.Context, runID string, events chan<- *StreamEvent) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, errors.New("agent: ResumeStreamAsk requires a checkpoint")
|
||||
}
|
||||
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("agent: checkpointed run not found")
|
||||
}
|
||||
if run.Status == "done" {
|
||||
var resp Response
|
||||
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
if terminalAgentRunStatus(run.Status) {
|
||||
return nil, errors.New("agent: checkpointed run is terminal with status " + run.Status)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.tools == nil {
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
}
|
||||
base := a.toolHandler()
|
||||
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
|
||||
result := base(ctx, call)
|
||||
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
|
||||
return result
|
||||
}
|
||||
a.setupWithToolHandler(handler)
|
||||
defer a.setupWithToolHandler(nil)
|
||||
if run.Status == "paused" {
|
||||
if run.State.Stage == agentInputStep {
|
||||
return nil, errors.New("agent: checkpointed run is input-required; resume with ResumeInput")
|
||||
}
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, string(run.State.Data), run.ParentID, &run, false)
|
||||
}
|
||||
|
||||
type agentStreamAdapter struct {
|
||||
stream AgentStream
|
||||
}
|
||||
|
||||
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
|
||||
for {
|
||||
event, err := s.stream.Recv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case StreamEventToken:
|
||||
if event.Token == "" {
|
||||
continue
|
||||
}
|
||||
return &ai.Response{Reply: event.Token}, nil
|
||||
case StreamEventDone:
|
||||
return nil, io.EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentStreamAdapter) Close() error {
|
||||
return s.stream.Close()
|
||||
}
|
||||
|
||||
func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream, error) {
|
||||
stream, err := a.StreamAsk(ctx, message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agentStreamAdapter{stream: stream}, nil
|
||||
}
|
||||
|
||||
type agentStream struct {
|
||||
events <-chan *StreamEvent
|
||||
done <-chan struct{}
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *agentStream) Recv() (*StreamEvent, error) {
|
||||
ev, ok := <-s.events
|
||||
if ok {
|
||||
return ev, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *agentStream) Close() error {
|
||||
<-s.done
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentStream) setErr(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.err = err
|
||||
}
|
||||
|
||||
func sendStreamEvent(ctx context.Context, events chan<- *StreamEvent, ev *StreamEvent) bool {
|
||||
select {
|
||||
case events <- ev:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func splitStreamTokens(reply string) []string {
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Fields(reply)
|
||||
if len(parts) == 0 {
|
||||
return []string{reply}
|
||||
}
|
||||
out := make([]string, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
part = " " + part
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("StreamAsk must configure a tool handler")
|
||||
}
|
||||
calls++
|
||||
result := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}})
|
||||
return &ai.Response{
|
||||
Reply: "planning",
|
||||
Answer: "final answer",
|
||||
ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}, Result: result.Content}},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("streamer"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
return input["text"].(string), nil
|
||||
}))
|
||||
stream, err := a.StreamAsk(context.Background(), "say hello")
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAsk: %v", err)
|
||||
}
|
||||
|
||||
var types []StreamEventType
|
||||
var tokens string
|
||||
var done *Response
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
types = append(types, event.Type)
|
||||
if event.Type == StreamEventToken {
|
||||
tokens += event.Token
|
||||
}
|
||||
if event.Type == StreamEventDone {
|
||||
done = event.Response
|
||||
}
|
||||
}
|
||||
|
||||
want := []StreamEventType{StreamEventToolStart, StreamEventToolEnd, StreamEventToken, StreamEventToken, StreamEventToken, StreamEventDone}
|
||||
if len(types) != len(want) {
|
||||
t.Fatalf("event types = %v, want %v", types, want)
|
||||
}
|
||||
for i := range want {
|
||||
if types[i] != want[i] {
|
||||
t.Fatalf("event types = %v, want %v", types, want)
|
||||
}
|
||||
}
|
||||
if tokens != "planning final answer" {
|
||||
t.Fatalf("tokens = %q", tokens)
|
||||
}
|
||||
if done == nil || done.Reply != "planning\n\nfinal answer" {
|
||||
t.Fatalf("done response = %#v", done)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("Generate calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
|
||||
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
|
||||
if err == nil {
|
||||
t.Fatal("StreamAsk helper should reject unsupported implementations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
|
||||
toolRuns := 0
|
||||
first := 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: "charge", Input: map[string]any{"order": "42"}})
|
||||
if res.Content != "charged" {
|
||||
t.Fatalf("tool result = %q, want charged", res.Content)
|
||||
}
|
||||
}
|
||||
if first {
|
||||
first = false
|
||||
return nil, errors.New("stream disconnected after tool")
|
||||
}
|
||||
return &ai.Response{Reply: "finished from streamed checkpoint"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("stream-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "charged", nil
|
||||
}))
|
||||
stream, err := a.StreamAsk(ctx, "charge order 42")
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAsk: %v", err)
|
||||
}
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after failed StreamAsk = %d, want 1", toolRuns)
|
||||
}
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending returned %d runs, want 1", len(runs))
|
||||
}
|
||||
|
||||
resumed, err := ResumeStreamAsk(ctx, a, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResumeStreamAsk: %v", err)
|
||||
}
|
||||
var toolEvents int
|
||||
var done *Response
|
||||
for {
|
||||
event, err := resumed.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resumed Recv: %v", err)
|
||||
}
|
||||
if event.Type == StreamEventToolStart || event.Type == StreamEventToolEnd {
|
||||
toolEvents++
|
||||
}
|
||||
if event.Type == StreamEventDone {
|
||||
done = event.Response
|
||||
}
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after ResumeStreamAsk = %d, want completed tool was not replayed", toolRuns)
|
||||
}
|
||||
if toolEvents != 2 {
|
||||
t.Fatalf("resumed tool events = %d, want start/end for replayed checkpoint result", toolEvents)
|
||||
}
|
||||
if done == nil || done.Reply != "finished from streamed checkpoint" || done.RunID != runs[0].ID {
|
||||
t.Fatalf("done response = %#v", done)
|
||||
}
|
||||
}
|
||||
|
||||
type unsupportedAgent struct{}
|
||||
|
||||
func (unsupportedAgent) Name() string { return "unsupported" }
|
||||
func (unsupportedAgent) Init(...Option) {}
|
||||
func (unsupportedAgent) Options() Options { return Options{} }
|
||||
func (unsupportedAgent) Ask(context.Context, string) (*Response, error) { return nil, nil }
|
||||
func (unsupportedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
|
||||
func (unsupportedAgent) Run() error { return nil }
|
||||
func (unsupportedAgent) Stop() error { return nil }
|
||||
func (unsupportedAgent) String() string { return "unsupported" }
|
||||
@@ -0,0 +1,141 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input map[string]any `json:"input"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// executeTextToolCalls is a compatibility fallback for providers that return a
|
||||
// tool call as text JSON instead of a structured tool_calls field. It only runs
|
||||
// calls whose names match the tools offered to the model, so ordinary JSON
|
||||
// answers are left untouched.
|
||||
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
handler := a.toolHandler()
|
||||
results := make([]string, 0, len(calls))
|
||||
for i := range calls {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, candidate := range jsonCandidates(text) {
|
||||
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonCandidates(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
var out []string
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
for _, match := range fencedJSONBlock.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 {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
|
||||
return nil
|
||||
}
|
||||
return collectTextToolCalls(root, allowed)
|
||||
}
|
||||
|
||||
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
var out []ai.ToolCall
|
||||
for _, item := range x {
|
||||
out = append(out, collectTextToolCalls(item, allowed)...)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
if nested, ok := firstNestedToolCalls(x); ok {
|
||||
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 {
|
||||
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}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func mapToTextToolCall(m map[string]any) textToolCall {
|
||||
b, _ := json.Marshal(m)
|
||||
var call textToolCall
|
||||
_ = json.Unmarshal(b, &call)
|
||||
return call
|
||||
}
|
||||
@@ -77,11 +77,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
"messages": threadAnthropicMessages(req),
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
@@ -101,10 +99,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
// Tool execution loop: execute tools, send results back, repeat
|
||||
// until the model responds with text only (no more tool calls)
|
||||
messages := []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": cleanContent(rawContent)},
|
||||
}
|
||||
messages := append(threadAnthropicMessages(req),
|
||||
map[string]any{"role": "assistant", "content": cleanContent(rawContent)},
|
||||
)
|
||||
|
||||
pendingCalls := resp.ToolCalls
|
||||
|
||||
@@ -127,7 +124,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": messages,
|
||||
}
|
||||
@@ -270,3 +267,24 @@ func cleanContent(raw any) any {
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// threadAnthropicMessages builds the Anthropic messages array from the
|
||||
// conversation history (req.Messages) followed by the current prompt. The
|
||||
// system prompt is sent separately via the top-level "system" field.
|
||||
func threadAnthropicMessages(req *ai.Request) []map[string]any {
|
||||
msgs := make([]map[string]any, 0, len(req.Messages)+1)
|
||||
for _, m := range req.Messages {
|
||||
msgs = append(msgs, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
msgs = append(msgs, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func anthropicMaxTokens(o ai.Options) int {
|
||||
if o.MaxTokens > 0 {
|
||||
return o.MaxTokens
|
||||
}
|
||||
return 8192
|
||||
}
|
||||
|
||||
+124
-3
@@ -20,12 +20,14 @@
|
||||
package atlascloud
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -42,6 +44,7 @@ func init() {
|
||||
ai.RegisterVideo("atlascloud", func(opts ...ai.Option) ai.VideoModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("atlascloud")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Atlas Cloud.
|
||||
@@ -54,7 +57,14 @@ func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
if options.Model == "" {
|
||||
options.Model = "deepseek-ai/DeepSeek-V3-0324"
|
||||
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
|
||||
// (e.g. to run CI conformance against a stronger tool-use model) without
|
||||
// a code change; fall back to a sensible default otherwise.
|
||||
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
|
||||
options.Model = m
|
||||
} else {
|
||||
options.Model = "deepseek-ai/DeepSeek-V3-0324"
|
||||
}
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.atlascloud.ai"
|
||||
@@ -91,13 +101,21 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
@@ -142,8 +160,111 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return nil, fmt.Errorf("%w: atlascloud provider", ai.ErrStreamingUnsupported)
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
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("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
return &atlasStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type atlasStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *atlasStream) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
// Final chunk (after include_usage) carries token usage and no content.
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *atlasStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,11 @@ package atlascloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -81,16 +85,58 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream, sawIncludeUsage bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
if so, ok := body["stream_options"].(map[string]any); ok {
|
||||
sawIncludeUsage, _ = so["include_usage"].(bool)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
if !sawIncludeUsage {
|
||||
t.Fatal("stream request did not set stream_options.include_usage=true")
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
usage, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("usage chunk error: %v", err)
|
||||
}
|
||||
if usage.Usage.TotalTokens != 9 || usage.Usage.InputTokens != 7 || usage.Usage.OutputTokens != 2 {
|
||||
t.Fatalf("usage = %#v; want input=7 output=2 total=9", usage.Usage)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"openai"}
|
||||
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func TestCapabilityRows(t *testing.T) {
|
||||
got := ai.CapabilityRows()
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: 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}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", 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}},
|
||||
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
|
||||
@@ -72,7 +72,7 @@ func TestCapabilityMatrix(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true}) {
|
||||
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
|
||||
@@ -88,7 +88,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"openai", "test-stream"}
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
+3
-1
@@ -22,12 +22,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/ai/internal/openaiapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("groq", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("groq")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: groq provider", ai.ErrStreamingUnsupported)
|
||||
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
+42
-3
@@ -2,7 +2,11 @@ package groq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package openaiapi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// Stream opens an OpenAI-compatible chat completions SSE stream.
|
||||
func Stream(ctx context.Context, opts ai.Options, req *ai.Request, basePath string) (ai.Stream, error) {
|
||||
messages := []map[string]any{{"role": "system", "content": req.SystemPrompt}}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = opts.MaxTokens
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(opts.BaseURL, "/") + basePath
|
||||
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("Authorization", "Bearer "+opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
return &StreamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
// StreamReader reads OpenAI-compatible server-sent event chunks.
|
||||
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, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *StreamReader) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/ai/internal/openaiapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("mistral", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("mistral")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: mistral provider", ai.ErrStreamingUnsupported)
|
||||
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,11 @@ package mistral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-10
@@ -93,9 +93,10 @@ func (c ToolCall) Scan(v any) error {
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
|
||||
// Refused names the reason a guardrail blocked the call before it ran
|
||||
// ("max_steps", "loop", "approval"); empty when the call executed. A
|
||||
// tool wrapper can switch on it to build reliability tooling — react to
|
||||
@@ -121,13 +122,16 @@ const (
|
||||
// tell which provider attempt produced the call and whether it is part of a
|
||||
// retry budget. They are zero when no model-attempt context is known.
|
||||
type RunInfo struct {
|
||||
RunID string // correlation id for this agent or flow run
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
Flow string // the flow's name, when the call is part of a workflow
|
||||
Step string // the flow step currently executing, when known
|
||||
Attempt int // current model Generate attempt, starting at 1 when known
|
||||
MaxAttempts int // configured model Generate attempt budget when known
|
||||
RunID string // correlation id for this agent or flow run
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
Flow string // the flow's name, when the call is part of a workflow
|
||||
Step string // the flow step currently executing, when known
|
||||
Attempt int // current model Generate attempt, starting at 1 when known
|
||||
MaxAttempts int // configured model Generate attempt budget when known
|
||||
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
|
||||
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
|
||||
Trigger string // external trigger or schedule label that started the run, when known
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
+38
-8
@@ -85,7 +85,12 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
// Build messages
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
@@ -93,6 +98,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
if len(openaiTools) > 0 {
|
||||
apiReq["tools"] = openaiTools
|
||||
@@ -146,12 +154,21 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
@@ -203,14 +220,27 @@ func (s *openAIStream) Recv() (*ai.Response, error) {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta.Content == "" {
|
||||
continue
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
// Final chunk (after include_usage) carries token usage and no content.
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
@@ -126,6 +127,58 @@ func TestProvider_Stream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {bad json}\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("Recv returned nil error for malformed chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamCloseReleasesResponse(t *testing.T) {
|
||||
released := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
close(released)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-released:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not observe closed stream request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImageRegistration(t *testing.T) {
|
||||
ig := ai.NewImage("openai", ai.WithAPIKey("test"))
|
||||
if ig == nil {
|
||||
|
||||
@@ -16,6 +16,8 @@ type Options struct {
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
// MaxTokens caps the length of the response (0 = provider default)
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
@@ -91,3 +93,11 @@ func WithTools(t *Tools) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxTokens caps the number of tokens in the response. 0 leaves the
|
||||
// provider default in place.
|
||||
func WithMaxTokens(n int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxTokens = n
|
||||
}
|
||||
}
|
||||
|
||||
+31
-10
@@ -13,6 +13,12 @@ type StatusCoder interface {
|
||||
StatusCode() int
|
||||
}
|
||||
|
||||
// RetryAfterCoder is implemented by provider errors that expose a server
|
||||
// supplied retry delay, such as HTTP Retry-After on a 429/503 response.
|
||||
type RetryAfterCoder interface {
|
||||
RetryAfter() time.Duration
|
||||
}
|
||||
|
||||
// ErrorKind classifies provider-boundary failures into stable buckets callers
|
||||
// can inspect without parsing provider-specific error strings.
|
||||
type ErrorKind string
|
||||
@@ -113,16 +119,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
// Always back off between retries — exponential and capped — so an
|
||||
// opt-in retry can never become a tight loop hammering the provider,
|
||||
// even if Backoff was left at zero.
|
||||
backoff := policy.Backoff
|
||||
if backoff <= 0 {
|
||||
backoff = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
backoff <<= shift
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
backoff := retryBackoff(err, attempt, policy.Backoff)
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -136,6 +133,30 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
|
||||
}
|
||||
|
||||
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
|
||||
backoff := base
|
||||
if backoff <= 0 {
|
||||
backoff = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
backoff <<= shift
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
|
||||
var retryAfter RetryAfterCoder
|
||||
if errors.As(err, &retryAfter) {
|
||||
if delay := retryAfter.RetryAfter(); delay > backoff {
|
||||
backoff = delay
|
||||
}
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return backoff
|
||||
}
|
||||
|
||||
// ClassifyError maps provider and context failures to stable operational kinds.
|
||||
func ClassifyError(err error) ErrorKind {
|
||||
if err == nil {
|
||||
|
||||
@@ -139,6 +139,14 @@ type statusErr int
|
||||
func (e statusErr) Error() string { return "provider status" }
|
||||
func (e statusErr) StatusCode() int { return int(e) }
|
||||
|
||||
type retryAfterErr struct {
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (e retryAfterErr) Error() string { return "rate limit exceeded" }
|
||||
func (e retryAfterErr) StatusCode() int { return 429 }
|
||||
func (e retryAfterErr) RetryAfter() time.Duration { return e.delay }
|
||||
|
||||
func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -181,3 +189,35 @@ func TestGenerateWithRetryExposesRetryErrorKind(t *testing.T) {
|
||||
t.Fatalf("retry error does not unwrap provider status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsRetryAfterWhenLongerThanBackoff(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, retryAfterErr{delay: 25 * time.Millisecond}
|
||||
}
|
||||
return &Response{Reply: "ok"}, nil
|
||||
}}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithRetry returned error: %v", err)
|
||||
}
|
||||
if resp.Reply != "ok" {
|
||||
t.Fatalf("reply = %q, want ok", resp.Reply)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 20*time.Millisecond {
|
||||
t.Fatalf("retry delay = %s, want RetryAfter delay to dominate base backoff", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
|
||||
if got := retryBackoff(retryAfterErr{delay: time.Minute}, 1, time.Millisecond); got != 30*time.Second {
|
||||
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package ai_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
func TestStreamProvidersConformToOpenAICompatibleSSE(t *testing.T) {
|
||||
providers := conformingStreamProviders(t)
|
||||
|
||||
for _, provider := range providers {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
var sawRequest bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawRequest = true
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", 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("Authorization"); got != "Bearer test-key" {
|
||||
t.Fatalf("Authorization = %q, want bearer API key", got)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body["model"] == "" {
|
||||
t.Fatal("request omitted model")
|
||||
}
|
||||
if body["stream"] != true {
|
||||
t.Fatalf("stream = %#v, want true", body["stream"])
|
||||
}
|
||||
streamOptions, ok := body["stream_options"].(map[string]any)
|
||||
if !ok || streamOptions["include_usage"] != true {
|
||||
t.Fatalf("stream_options = %#v, want include_usage=true", body["stream_options"])
|
||||
}
|
||||
messages, ok := body["messages"].([]any)
|
||||
if !ok || len(messages) != 4 {
|
||||
t.Fatalf("messages = %#v, want system + history + prompt", body["messages"])
|
||||
}
|
||||
wantRoles := []string{"system", "user", "assistant", "user"}
|
||||
for i, wantRole := range wantRoles {
|
||||
message, ok := messages[i].(map[string]any)
|
||||
if !ok || message["role"] != wantRole {
|
||||
t.Fatalf("message[%d] = %#v, want role %q", i, messages[i], wantRole)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(": keepalive\n\n"))
|
||||
_, _ = w.Write([]byte("event: ignored\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
model := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
if model == nil {
|
||||
t.Fatalf("ai.New(%q) returned nil", provider)
|
||||
}
|
||||
stream, err := model.Stream(context.Background(), &ai.Request{
|
||||
SystemPrompt: "system",
|
||||
Messages: []ai.Message{
|
||||
{Role: "user", Content: "previous question"},
|
||||
{Role: "assistant", Content: "previous answer"},
|
||||
},
|
||||
Prompt: "current question",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawRequest {
|
||||
t.Fatal("server did not receive stream request")
|
||||
}
|
||||
|
||||
assertStreamReply(t, stream, "hel")
|
||||
assertStreamReply(t, stream, "lo")
|
||||
usage, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("usage chunk error: %v", err)
|
||||
}
|
||||
if usage.Reply != "" || usage.Usage != (ai.Usage{InputTokens: 3, OutputTokens: 2, TotalTokens: 5}) {
|
||||
t.Fatalf("usage chunk = %#v", usage)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamProvidersCloseCancelsInFlightRequest(t *testing.T) {
|
||||
for _, provider := range conformingStreamProviders(t) {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
released := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
close(released)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
assertStreamReply(t, stream, "hel")
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("second Close returned error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-released:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not observe canceled stream request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamProvidersPropagateProviderErrors(t *testing.T) {
|
||||
for _, provider := range conformingStreamProviders(t) {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "upstream quota exhausted", http.StatusTooManyRequests)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err == nil {
|
||||
_ = stream.Close()
|
||||
t.Fatal("Stream returned nil error for provider failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "upstream quota exhausted") {
|
||||
t.Fatalf("Stream error = %v, want provider status and body", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "test-key") {
|
||||
t.Fatal("provider error leaked API key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamProvidersHonorCanceledContextBeforeRequest(t *testing.T) {
|
||||
for _, provider := range conformingStreamProviders(t) {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
var sawRequest bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawRequest = true
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(ctx, &ai.Request{Prompt: "Hello"})
|
||||
if err == nil {
|
||||
_ = stream.Close()
|
||||
t.Fatal("Stream returned nil error for canceled context")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Stream error = %v, want context.Canceled", err)
|
||||
}
|
||||
if sawRequest {
|
||||
t.Fatal("provider sent request after context was already canceled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provider string
|
||||
keyEnv string
|
||||
modelEnv string
|
||||
}{
|
||||
{provider: "openai", keyEnv: "OPENAI_API_KEY", modelEnv: "OPENAI_MODEL"},
|
||||
{provider: "groq", keyEnv: "GROQ_API_KEY", modelEnv: "GROQ_MODEL"},
|
||||
{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"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
key := os.Getenv(tc.keyEnv)
|
||||
if key == "" {
|
||||
t.Skipf("%s not set; skipping configured provider stream check", tc.keyEnv)
|
||||
}
|
||||
|
||||
opts := []ai.Option{ai.WithAPIKey(key)}
|
||||
if model := os.Getenv(tc.modelEnv); model != "" {
|
||||
opts = append(opts, ai.WithModel(model))
|
||||
}
|
||||
stream, err := ai.New(tc.provider, opts...).Stream(context.Background(), &ai.Request{Prompt: "Reply with exactly: ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
deadline := time.After(30 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for provider stream chunk")
|
||||
default:
|
||||
}
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
t.Fatal("provider stream ended without content")
|
||||
}
|
||||
t.Fatalf("Recv returned error: %v", err)
|
||||
}
|
||||
if chunk.Reply != "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"anthropic", "gemini"} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities(provider); caps.Stream {
|
||||
t.Fatalf("ProviderCapabilities(%q).Stream = true, want false", provider)
|
||||
}
|
||||
_, err := ai.New(provider, ai.WithAPIKey("test-key")).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "test-key") {
|
||||
t.Fatal("streaming unsupported error leaked API key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func conformingStreamProviders(t *testing.T) []string {
|
||||
t.Helper()
|
||||
providers := ai.RegisteredProviders("stream")
|
||||
allowed := map[string]struct{}{
|
||||
"atlascloud": {},
|
||||
"groq": {},
|
||||
"mistral": {},
|
||||
"openai": {},
|
||||
"together": {},
|
||||
}
|
||||
var out []string
|
||||
for _, provider := range providers {
|
||||
if _, ok := allowed[provider]; ok {
|
||||
out = append(out, provider)
|
||||
}
|
||||
}
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertStreamReply(t *testing.T, stream ai.Stream, want string) {
|
||||
t.Helper()
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv error = %v, want reply %q", err, want)
|
||||
}
|
||||
if chunk.Reply != want {
|
||||
t.Fatalf("Reply = %q, want %q", chunk.Reply, want)
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/ai/internal/openaiapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("together", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("together")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: together provider", ai.ErrStreamingUnsupported)
|
||||
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,11 @@ package together
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
-38
@@ -42,14 +42,31 @@ func Deploy(c *cli.Context) error {
|
||||
return showDeployHelp()
|
||||
}
|
||||
|
||||
target, remotePath := resolveDeployTarget(c, target, cfg)
|
||||
if c.Bool("dry-run") {
|
||||
return printDeployPlan(c, target, cfg, remotePath)
|
||||
}
|
||||
|
||||
return deploySSH(c, target, cfg, remotePath)
|
||||
}
|
||||
|
||||
func resolveDeployTarget(c *cli.Context, target string, cfg *config.Config) (string, string) {
|
||||
remotePath := c.String("path")
|
||||
if remotePath == "" {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
|
||||
// Check if target is a named target from config
|
||||
if cfg != nil {
|
||||
if dt, ok := cfg.Deploy[target]; ok {
|
||||
target = dt.SSH
|
||||
if dt.Path != "" && !c.IsSet("path") {
|
||||
remotePath = dt.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deploySSH(c, target, cfg)
|
||||
return target, remotePath
|
||||
}
|
||||
|
||||
func showDeployHelp() error {
|
||||
@@ -82,7 +99,82 @@ func showDeployTargets(cfg *config.Config) error {
|
||||
return fmt.Errorf("%s", sb.String())
|
||||
}
|
||||
|
||||
func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
func printDeployPlan(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
|
||||
dir := c.Args().Get(1)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
cfg, _ = config.Load(absDir)
|
||||
}
|
||||
if remotePath == "" {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
|
||||
services, err := deployServices(absDir, cfg, c.String("service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro deploy --dry-run\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Target \033[36m%s\033[0m\n", target)
|
||||
fmt.Printf(" Remote path %s\n", remotePath)
|
||||
fmt.Printf(" Services %s\n", strings.Join(services, ", "))
|
||||
fmt.Println()
|
||||
fmt.Println(" Plan:")
|
||||
fmt.Println(" 1. Build linux/amd64 service binaries")
|
||||
fmt.Printf(" 2. Copy binaries to %s/bin/\n", remotePath)
|
||||
fmt.Println(" 3. Enable and restart micro@<service> systemd units")
|
||||
fmt.Println(" 4. Check service health")
|
||||
fmt.Println()
|
||||
fmt.Println(" No SSH, rsync, systemd, or remote deployment was performed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func deployServices(absDir string, cfg *config.Config, filterService string) ([]string, error) {
|
||||
if filterService != "" && cfg != nil {
|
||||
found := false
|
||||
for _, svc := range cfg.Services {
|
||||
if svc.Name == filterService {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(cfg.Services) > 0 {
|
||||
return nil, fmt.Errorf("service '%s' not found in configuration", filterService)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg != nil && len(cfg.Services) > 0 {
|
||||
sorted, err := cfg.TopologicalSort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services := make([]string, 0, len(sorted))
|
||||
for _, svc := range sorted {
|
||||
if filterService == "" || svc.Name == filterService {
|
||||
services = append(services, svc.Name)
|
||||
}
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
services := []string{filepath.Base(absDir)}
|
||||
if filterService != "" && filterService != services[0] {
|
||||
return nil, fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
|
||||
dir := c.Args().Get(1)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
@@ -98,7 +190,6 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
cfg, _ = config.Load(absDir)
|
||||
}
|
||||
|
||||
remotePath := c.String("path")
|
||||
if remotePath == "" {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
@@ -108,19 +199,10 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
fmt.Println()
|
||||
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
|
||||
|
||||
// Early validation: Check if the requested service exists before SSH checks
|
||||
filterService := c.String("service")
|
||||
if filterService != "" && cfg != nil {
|
||||
found := false
|
||||
for _, svc := range cfg.Services {
|
||||
if svc.Name == filterService {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(cfg.Services) > 0 {
|
||||
return fmt.Errorf("service '%s' not found in configuration", filterService)
|
||||
}
|
||||
// Early validation: resolve services before SSH checks.
|
||||
services, err := deployServices(absDir, cfg, c.String("service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Check SSH connectivity
|
||||
@@ -140,28 +222,6 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
fmt.Println("\u2713")
|
||||
|
||||
// Step 3: Build binaries
|
||||
var services []string
|
||||
if cfg != nil && len(cfg.Services) > 0 {
|
||||
sorted, err := cfg.TopologicalSort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, svc := range sorted {
|
||||
// If --service flag is provided, only include that service
|
||||
if filterService == "" || svc.Name == filterService {
|
||||
services = append(services, svc.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single service project
|
||||
services = []string{filepath.Base(absDir)}
|
||||
|
||||
// If --service flag was provided for a single-service project, validate it matches
|
||||
if filterService != "" && filterService != services[0] {
|
||||
return fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Building binaries... ")
|
||||
if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil {
|
||||
fmt.Println("\u2717")
|
||||
@@ -480,6 +540,10 @@ The deploy process:
|
||||
Name: "service",
|
||||
Usage: "Deploy only a specific service (for multi-service projects)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
Usage: "Print the deployment plan without building, connecting, copying, or restarting services",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/cmd/micro/run/config"
|
||||
)
|
||||
|
||||
func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
set := flag.NewFlagSet("deploy", flag.ContinueOnError)
|
||||
set.String("path", defaultRemotePath, "")
|
||||
set.String("ssh", "", "")
|
||||
set.String("service", "", "")
|
||||
set.Bool("build", false, "")
|
||||
set.Bool("dry-run", false, "")
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse flags: %v", err)
|
||||
}
|
||||
return cli.NewContext(cli.NewApp(), set, nil)
|
||||
}
|
||||
|
||||
func TestDeployNoTargetExplainsInitAndDeployHandoff(t *testing.T) {
|
||||
err := showDeployHelp()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing target guidance")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"no deployment target specified",
|
||||
"sudo micro init --server",
|
||||
"micro deploy user@your-server",
|
||||
"deploy prod",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in guidance:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployListsConfiguredTargetsWhenNoTargetProvided(t *testing.T) {
|
||||
err := showDeployTargets(&config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com"},
|
||||
"staging": {Name: "staging", SSH: "deploy@staging.example.com"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected configured target guidance")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"Available deploy targets:",
|
||||
"prod -> deploy@prod.example.com",
|
||||
"staging -> deploy@staging.example.com",
|
||||
"micro deploy <target>",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in configured target guidance:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeployTargetUsesConfigTargetAndPath(t *testing.T) {
|
||||
ctx := newDeployTestContext(t, "prod")
|
||||
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
|
||||
}}
|
||||
|
||||
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
|
||||
if target != "deploy@prod.example.com" {
|
||||
t.Fatalf("target = %q, want configured SSH", target)
|
||||
}
|
||||
if remotePath != "/srv/micro" {
|
||||
t.Fatalf("remotePath = %q, want configured path", remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeployTargetAllowsCLIPathOverride(t *testing.T) {
|
||||
ctx := newDeployTestContext(t, "--path", "/tmp/micro", "prod")
|
||||
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
|
||||
}}
|
||||
|
||||
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
|
||||
if target != "deploy@prod.example.com" {
|
||||
t.Fatalf("target = %q, want configured SSH", target)
|
||||
}
|
||||
if remotePath != "/tmp/micro" {
|
||||
t.Fatalf("remotePath = %q, want CLI override", remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployConfigParserSupportsDeployTargets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/micro.mu"
|
||||
content := `service api
|
||||
path ./api
|
||||
|
||||
deploy prod
|
||||
ssh deploy@prod.example.com
|
||||
path /srv/micro
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.ParseMu(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
prod := cfg.Deploy["prod"]
|
||||
if prod == nil {
|
||||
t.Fatal("missing prod deploy target")
|
||||
}
|
||||
if prod.SSH != "deploy@prod.example.com" || prod.Path != "/srv/micro" {
|
||||
t.Fatalf("deploy target = %#v", prod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployDryRunPlansConfiguredTargetWithoutRemoteSideEffects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(dir+"/micro.mu", []byte(`service api
|
||||
path ./api
|
||||
|
||||
deploy prod
|
||||
ssh deploy@prod.example.com
|
||||
path /srv/micro
|
||||
`), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(oldwd); err != nil {
|
||||
t.Errorf("restore cwd: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := newDeployTestContext(t, "--dry-run", "prod")
|
||||
if err := Deploy(ctx); err != nil {
|
||||
t.Fatalf("dry-run deploy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployDryRunValidatesRequestedService(t *testing.T) {
|
||||
ctx := newDeployTestContext(t, "--dry-run", "--service", "missing", "prod")
|
||||
cfg := &config.Config{Services: map[string]*config.Service{
|
||||
"api": {Name: "api", Path: "./api"},
|
||||
}}
|
||||
|
||||
err := printDeployPlan(ctx, "deploy@prod.example.com", cfg, defaultRemotePath)
|
||||
if err == nil {
|
||||
t.Fatal("expected dry-run to validate service names")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "service 'missing' not found in configuration") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// TestZeroToOneContract locks the documented getting-started path:
|
||||
// `micro new helloworld` must produce an ordinary Go service that the Go
|
||||
// toolchain can build. The generated module is pointed back at this checkout
|
||||
// so the contract stays local and deterministic in CI.
|
||||
// toolchain can build, run long enough to start, and call through its generated
|
||||
// handler. The generated module is pointed back at this checkout so the
|
||||
// contract stays local and deterministic in CI.
|
||||
//
|
||||
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
|
||||
// it needs the Go toolchain and module access; it is skipped under `-short`.
|
||||
@@ -29,6 +32,8 @@ func TestZeroToOneContract(t *testing.T) {
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
generated.run(t)
|
||||
generated.call(t, "Alice", "Hello Alice")
|
||||
}
|
||||
|
||||
// TestZeroToOneNoMCPContract keeps the MCP opt-out path honest. Some services
|
||||
@@ -48,6 +53,8 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
generated.run(t)
|
||||
generated.call(t, "Bob", "Hello Bob")
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
@@ -121,3 +128,74 @@ func (g generatedService) build(t *testing.T) {
|
||||
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) run(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
bin := filepath.Join(g.dir, "service-contract")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Dir = g.dir
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generated service go build -o service-contract . failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Dir = g.dir
|
||||
var out strings.Builder
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("generated service failed to start: %v\n%s", err, out.String())
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("generated service exited early: %v\n%s", err, out.String())
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("failed to stop generated service: %v\n%s", err, out.String())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("generated service did not stop after kill\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) call(t *testing.T, name, want string) {
|
||||
t.Helper()
|
||||
|
||||
testPath := filepath.Join(g.dir, "handler", "contract_test.go")
|
||||
testSrc := `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedCallContract(t *testing.T) {
|
||||
rsp := new(Response)
|
||||
if err := New().Call(context.Background(), &Request{Name: "` + name + `"}, rsp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rsp.Msg != "` + want + `" {
|
||||
t.Fatalf("Call response = %q, want %q", rsp.Msg, "` + want + `")
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(testPath, []byte(testSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "test", "./handler", "-run", "TestGeneratedCallContract", "-count=1")
|
||||
cmd.Dir = g.dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service call contract failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package inspect registers the 'micro inspect' CLI command.
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/cmd"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "inspect",
|
||||
Usage: "Inspect recent agent and workflow activity",
|
||||
Description: `Inspect is the CLI checkpoint in the local scaffold → run → chat → inspect loop.
|
||||
It reads durable local run history, so it works after the agent or flow has stopped.`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "agent",
|
||||
Usage: "Show recent recorded runs for an agent",
|
||||
ArgsUsage: "[agent]",
|
||||
Flags: inspectAgentFlags(),
|
||||
Action: inspectAgent,
|
||||
},
|
||||
{
|
||||
Name: "flow",
|
||||
Usage: "Show durable run history for a flow",
|
||||
ArgsUsage: "[flow]",
|
||||
Flags: inspectFlowFlags(),
|
||||
Action: inspectFlow,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func inspectAgentFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run summaries as JSON for automation"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
|
||||
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
}
|
||||
}
|
||||
|
||||
func inspectFlowFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print durable run history as JSON for automation"},
|
||||
&cli.BoolFlag{Name: "pending", Usage: "Only show runs that have not completed"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, failed)"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
&cli.StringFlag{Name: "stage", Usage: "Only show runs currently checkpointed at this stage"},
|
||||
}
|
||||
}
|
||||
|
||||
func inspectAgent(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("agent name required: micro inspect agent <name>")
|
||||
}
|
||||
opts := goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
|
||||
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAgentInspection(os.Stdout, name, runs, c.Bool("json"))
|
||||
}
|
||||
|
||||
func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
fmt.Fprintf(w, " No agent runs recorded for %q. After chatting, try: micro inspect agent %s\n", name, name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, " Agent %q runs\n", name)
|
||||
for _, run := range runs {
|
||||
fmt.Fprintf(w, " %s status=%s events=%d last=%s", run.RunID, run.Status, run.Events, run.LastKind)
|
||||
if run.LastError != "" {
|
||||
fmt.Fprintf(w, " error=%q", run.LastError)
|
||||
}
|
||||
if run.TraceID != "" {
|
||||
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectFlow(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("flow name required: micro inspect flow <name>")
|
||||
}
|
||||
runs, err := aiflow.StoreCheckpoint(nil, name).List(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runs = filterFlowInspection(runs, c.Bool("pending"), c.String("status"), c.String("stage"), c.Int("limit"))
|
||||
return writeFlowInspection(os.Stdout, name, runs, c.Bool("json"), c.Bool("pending"))
|
||||
}
|
||||
|
||||
func filterFlowInspection(runs []aiflow.Run, pending bool, status, stage string, limit int) []aiflow.Run {
|
||||
filtered := make([]aiflow.Run, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
if pending && run.Status == "done" {
|
||||
continue
|
||||
}
|
||||
if status != "" && run.Status != status {
|
||||
continue
|
||||
}
|
||||
if stage != "" && run.State.Stage != stage {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, run)
|
||||
}
|
||||
if limit > 0 && len(filtered) > limit {
|
||||
return filtered[len(filtered)-limit:]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func writeFlowInspection(w io.Writer, name string, runs []aiflow.Run, asJSON, pending bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
if pending {
|
||||
fmt.Fprintf(w, " No pending flow runs recorded for %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, " No flow runs recorded for %q. After executing a durable flow, try: micro inspect flow %s\n", name, name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, " Flow %q runs\n", name)
|
||||
for _, run := range runs {
|
||||
stage := run.State.Stage
|
||||
if stage == "" {
|
||||
stage = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s status=%s stage=%s steps=%d", shortID(run.ID), run.Status, stage, len(run.Steps))
|
||||
for _, step := range run.Steps {
|
||||
if step.Error != "" {
|
||||
fmt.Fprintf(w, " error=%q", step.Error)
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef"}}
|
||||
var out bytes.Buffer
|
||||
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", `error="boom"`, "trace=1234567890ab"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAgentInspectionEmptyStateNamesInspectCommand(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
if err := writeAgentInspection(&out, "support", nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.String(); !strings.Contains(got, "micro inspect agent support") {
|
||||
t.Fatalf("empty state missing next step: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFlowInspectionIncludesFailedStepBreadcrumb(t *testing.T) {
|
||||
runs := []aiflow.Run{{ID: "1234567890abcdef", Status: "failed", State: aiflow.State{Stage: "charge"}, Steps: []aiflow.StepRecord{{Name: "charge", Status: "failed", Error: "card declined"}}}}
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowInspection(&out, "checkout", runs, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"Flow \"checkout\" runs", "1234567890ab", "status=failed", "stage=charge", "steps=1", `error="card declined"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFlowInspectionJSON(t *testing.T) {
|
||||
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowInspection(&out, "checkout", runs, true, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []aiflow.Run
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "run-1" || got[0].Status != "done" {
|
||||
t.Fatalf("decoded runs = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/build"
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/deploy"
|
||||
_ "go-micro.dev/v6/cmd/micro/flow"
|
||||
_ "go-micro.dev/v6/cmd/micro/inspect"
|
||||
_ "go-micro.dev/v6/cmd/micro/mcp"
|
||||
_ "go-micro.dev/v6/cmd/micro/resource"
|
||||
_ "go-micro.dev/v6/cmd/micro/run"
|
||||
|
||||
@@ -498,10 +498,13 @@ func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool,
|
||||
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
|
||||
// MCP tools are served on the gateway by default — every endpoint is an
|
||||
// AI-callable tool, so surface it rather than hiding it behind a flag.
|
||||
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
|
||||
if mcpAddr != "" {
|
||||
fmt.Printf(" MCP \033[36mhttp://localhost%s\033[0m\n", mcpAddr)
|
||||
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", mcpAddr)
|
||||
// Optional standalone MCP protocol server (e.g. for MCP clients).
|
||||
fmt.Printf(" MCP Server \033[36mhttp://localhost%s\033[0m (full MCP protocol)\n", mcpAddr)
|
||||
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func TestZeroToHeroCLIBoundaries(t *testing.T) {
|
||||
commands := map[string]bool{}
|
||||
subcommands := map[string]map[string]bool{}
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
commands[command.Name] = true
|
||||
for _, subcommand := range command.Subcommands {
|
||||
if subcommands[command.Name] == nil {
|
||||
subcommands[command.Name] = map[string]bool{}
|
||||
}
|
||||
subcommands[command.Name][subcommand.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("missing %q command", want)
|
||||
}
|
||||
}
|
||||
if !subcommands["flow"]["runs"] {
|
||||
t.Fatal("missing inspect boundary: flow runs")
|
||||
}
|
||||
if !subcommands["inspect"]["agent"] || !subcommands["inspect"]["flow"] {
|
||||
t.Fatal("missing inspect boundary: inspect agent/flow")
|
||||
}
|
||||
|
||||
var hasDeployDryRun bool
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
if command.Name != "deploy" {
|
||||
continue
|
||||
}
|
||||
for _, flag := range command.Flags {
|
||||
for _, name := range flag.Names() {
|
||||
if name == "dry-run" {
|
||||
hasDeployDryRun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasDeployDryRun {
|
||||
t.Fatal("missing deploy boundary: deploy --dry-run")
|
||||
}
|
||||
}
|
||||
@@ -324,4 +324,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
|
||||
@@ -102,4 +102,4 @@ pytest tests/integration/ -v
|
||||
## Questions?
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
|
||||
@@ -370,4 +370,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
|
||||
+5
-1
@@ -19,7 +19,11 @@ func NewStream(opts ...Option) (Stream, error) {
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
return &mem{store: store.NewMemoryStore()}, nil
|
||||
st := options.Store
|
||||
if st == nil {
|
||||
st = store.NewMemoryStore()
|
||||
}
|
||||
return &mem{store: st}, nil
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
|
||||
+16
-2
@@ -1,11 +1,25 @@
|
||||
package events
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
type Options struct{}
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
// Store persists published events for durability and replay. If nil, an
|
||||
// in-memory store is used and events do not survive a restart.
|
||||
Store store.Store
|
||||
}
|
||||
|
||||
type Option func(o *Options)
|
||||
|
||||
// WithStore backs the stream with a durable store (e.g. the file store), so
|
||||
// published events persist and can be replayed across restarts.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(o *Options) { o.Store = s }
|
||||
}
|
||||
|
||||
type StoreOptions struct {
|
||||
TTL time.Duration
|
||||
Backup Backup
|
||||
|
||||
+5
-4
@@ -80,10 +80,11 @@ A workflow as ordered, checkpointed steps that survives a crash and resumes wher
|
||||
- **Checkpoint** — each step is persisted; on `Resume`, completed steps are not re-run (no duplicate side effects)
|
||||
|
||||
### [support](./support/)
|
||||
A real-world support desk — the "zero to hero" shape in one runnable file:
|
||||
- **services** (`customers`, `tickets`, `notify`) become the agent's tools automatically
|
||||
- **flow** turns a `ticket.created` event into work for the agent (the event is the prompt)
|
||||
- **guardrail** — the agent triages freely but can't email a customer without passing the approval gate
|
||||
A maintained 0-to-hero reference path in one runnable file:
|
||||
- **scaffold** typed `customers`, `tickets`, and `notify` services
|
||||
- **run/chat** with a support agent that uses those services as tools
|
||||
- **inspect** the event-driven `intake` flow and approval gate
|
||||
- **CI** keeps the deterministic mock-model journey runnable with `go test ./examples/support`
|
||||
|
||||
## Coming Soon
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Durable agent run resume
|
||||
|
||||
This example shows the agent-side counterpart to `examples/flow-durable`: an
|
||||
agent run is checkpointed with the same `Checkpoint` interface used by flows,
|
||||
then resumed after an interruption without repeating a completed side effect.
|
||||
The sample uses an in-memory store to keep repeated local runs deterministic;
|
||||
use your service store for process-restart recovery.
|
||||
|
||||
Run it with:
|
||||
|
||||
```sh
|
||||
go run ./examples/agent-durable
|
||||
```
|
||||
|
||||
The demo model calls `inventory.reserve`, then fails to mimic a process dying
|
||||
after the tool call was checkpointed. `micro.AgentPending` finds the unfinished
|
||||
run and `micro.AgentResume` continues it from the saved checkpoint. The final
|
||||
`tool executions: 1` line is the important bit: the reservation tool was not
|
||||
called a second time during resume.
|
||||
|
||||
## When to use this instead of a durable flow
|
||||
|
||||
Use a durable flow when the path is known ahead of time: ordered service calls,
|
||||
retries, timers, compensation, and a precise resume stage such as `reserve` or
|
||||
`charge`. Use a checkpointed agent run when the path is open-ended and the model
|
||||
may choose tools dynamically, but completed tool side effects still must not be
|
||||
replayed after a crash or provider failure.
|
||||
|
||||
They compose: keep deterministic business process in `flow-durable`, then hand
|
||||
off the judgment-heavy step to a checkpointed agent when the workflow needs
|
||||
model-directed tool use. Both use the same `Checkpoint` backend, so inspection
|
||||
and recovery can share one run-history store.
|
||||
|
||||
In a service, use the same pattern at startup:
|
||||
|
||||
```go
|
||||
pending, _ := micro.AgentPending(ctx, agent)
|
||||
for _, run := range pending {
|
||||
_, _ = micro.AgentResume(ctx, agent, run.ID)
|
||||
}
|
||||
```
|
||||
|
||||
`context.Context` cancellation and deadlines are still honored by checkpoint
|
||||
loads/saves, model calls, and tool calls. Runs with terminal statuses such as
|
||||
`done`, `canceled`, and `expired` are not returned by `AgentPending`.
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package main demonstrates durable agent runs: a checkpointed agent can
|
||||
// resume after a crash without re-executing completed tool calls.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
micro "go-micro.dev/v6"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
checkpoint := micro.StoreCheckpoint(store.NewMemoryStore(), "durable-agent-demo")
|
||||
model := &demoModel{failFirst: true}
|
||||
ai.Register("durable-demo", func(opts ...ai.Option) ai.Model {
|
||||
_ = model.Init(opts...)
|
||||
return model
|
||||
})
|
||||
var reservations atomic.Int32
|
||||
|
||||
ag := micro.NewAgent("durable-agent-demo",
|
||||
micro.AgentWithCheckpoint(checkpoint),
|
||||
micro.AgentProvider("durable-demo"),
|
||||
micro.AgentTool("inventory.reserve", "reserve inventory exactly once", map[string]any{
|
||||
"sku": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
count := reservations.Add(1)
|
||||
return fmt.Sprintf("reserved %s (execution %d)", input["sku"], count), nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := ag.Ask(ctx, "reserve sku-123 and confirm")
|
||||
fmt.Println("initial run:", err)
|
||||
|
||||
pending, err := micro.AgentPending(ctx, ag)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
panic("expected a checkpointed run to resume")
|
||||
}
|
||||
|
||||
resp, err := micro.AgentResume(ctx, ag, pending[0].ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("resumed reply:", resp.Reply)
|
||||
fmt.Println("tool executions:", reservations.Load())
|
||||
}
|
||||
|
||||
type demoModel struct {
|
||||
failFirst bool
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func (m *demoModel) Init(opts ...ai.Option) error {
|
||||
m.opts = ai.NewOptions(opts...)
|
||||
return nil
|
||||
}
|
||||
func (m *demoModel) Options() ai.Options { return m.opts }
|
||||
func (m *demoModel) String() string { return "durable-demo" }
|
||||
|
||||
func (m *demoModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
res := m.opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "reserve-1",
|
||||
Name: "inventory.reserve",
|
||||
Input: map[string]any{"sku": "sku-123"},
|
||||
})
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("reservation tool returned no content")
|
||||
}
|
||||
}
|
||||
if m.failFirst {
|
||||
m.failFirst = false
|
||||
return nil, errors.New("simulated process interruption after checkpointed tool call")
|
||||
}
|
||||
return &ai.Response{Reply: "sku-123 is reserved; no duplicate reservation was made"}, nil
|
||||
}
|
||||
|
||||
func (m *demoModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDurableAgentExampleResumesWithoutReplayingTool(t *testing.T) {
|
||||
out := captureStdout(t, main)
|
||||
if !strings.Contains(out, "simulated process interruption after checkpointed tool call") {
|
||||
t.Fatalf("example output %q did not show the initial interrupted run", out)
|
||||
}
|
||||
if !strings.Contains(out, "resumed reply: sku-123 is reserved; no duplicate reservation was made") {
|
||||
t.Fatalf("example output %q did not show the resumed response", out)
|
||||
}
|
||||
if !strings.Contains(out, "tool executions: 1") {
|
||||
t.Fatalf("example output %q did not prove the tool was not replayed", out)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe stdout: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
var buf bytes.Buffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(&buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
<-done
|
||||
_ = r.Close()
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Agent Human Input Pause/Resume
|
||||
|
||||
Agents can pause a durable run when the model needs a human decision before it
|
||||
can continue. This keeps the services → agents → workflows lifecycle in one
|
||||
runtime: services expose tools, the agent decides it needs operator input, and
|
||||
the same checkpointed run resumes once that input arrives.
|
||||
|
||||
## Pattern
|
||||
|
||||
```go
|
||||
cp := flow.StoreCheckpoint(nil, "deploy-agent")
|
||||
ag := agent.New(
|
||||
agent.Name("deploy-agent"),
|
||||
agent.WithCheckpoint(cp),
|
||||
)
|
||||
|
||||
resp, err := ag.Ask(ctx, "Deploy the service")
|
||||
if err != nil {
|
||||
// If the model called the built-in request_input tool, the run is saved as
|
||||
// paused/input-required instead of losing state or completing early.
|
||||
pending, _ := agent.Pending(ctx, ag)
|
||||
runID := pending[0].ID
|
||||
|
||||
// Later, after an operator supplies the missing answer, the same run ID
|
||||
// continues with the original prompt, human input, memory, and completed
|
||||
// tool history intact.
|
||||
resp, err = agent.ResumeInput(ctx, ag, runID, "Deploy to us-east-1")
|
||||
}
|
||||
_ = resp
|
||||
```
|
||||
|
||||
The model sees a built-in `request_input` tool with a `prompt` argument. When it
|
||||
calls that tool, Go Micro persists the run with status `paused` and stage
|
||||
`input-required`. Plain `agent.Resume` continues to support completed, failed,
|
||||
and approval-paused runs; input-required runs are resumed with
|
||||
`agent.ResumeInput` so the human response is explicit.
|
||||
|
||||
## Cancellation and deadlines
|
||||
|
||||
`ResumeInput` uses the caller's `context.Context` for checkpoint reads, writes,
|
||||
and the resumed model/tool turn. If the context is canceled or its deadline
|
||||
expires before the resume is committed, the call returns the context error and
|
||||
the checkpointed run remains `paused` at `input-required`; list it with
|
||||
`agent.Pending` and retry with a fresh context once the operator is ready.
|
||||
@@ -1,9 +1,25 @@
|
||||
# Support desk
|
||||
# Zero-to-hero support desk
|
||||
|
||||
A real-world agent built the Go Micro way: a few services, an agent that
|
||||
manages them, an event that triggers it, and a human-in-the-loop gate on the
|
||||
one action that touches a customer. It's the "zero to hero" shape in one
|
||||
runnable file.
|
||||
A maintained 0-to-hero reference for the Go Micro lifecycle: scaffold a few
|
||||
typed services, run them in one process, let an agent chat with those services
|
||||
as tools, then inspect the durable flow that triggered the work. It is one
|
||||
runnable file and one CI smoke test, so the reference path stays honest as the
|
||||
framework evolves.
|
||||
|
||||
## The path
|
||||
|
||||
1. **Scaffold services** — `customers`, `tickets`, and `notify` are ordinary
|
||||
typed Go Micro services. Their request/response structs and method comments
|
||||
become the tool contract the agent sees.
|
||||
2. **Run the harness** — the example starts an in-memory registry, broker,
|
||||
client, store, services, agent, and flow in one process; no external
|
||||
dependencies or API key are required for the default run.
|
||||
3. **Chat through an agent** — the `support` agent receives the ticket event as
|
||||
a prompt and calls service tools to look up the customer, triage the ticket,
|
||||
and draft a reply.
|
||||
4. **Inspect the workflow** — the `intake` flow records the event-driven run and
|
||||
prints the agent result, showing the service → agent → workflow lifecycle as
|
||||
one runtime.
|
||||
|
||||
## The scenario
|
||||
|
||||
@@ -44,6 +60,12 @@ agent, which:
|
||||
go run main.go # mock model — deterministic, no API key
|
||||
```
|
||||
|
||||
The maintained check is the same deterministic path:
|
||||
|
||||
```bash
|
||||
go test ./examples/support
|
||||
```
|
||||
|
||||
Against a live model, the agent reasons about the ticket itself instead of
|
||||
following the script:
|
||||
|
||||
|
||||
+31
-19
@@ -212,37 +212,39 @@ func waitFor(reg registry.Registry, names ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
|
||||
func runSupport(provider string) error {
|
||||
apiKey := ""
|
||||
if *provider == "mock" {
|
||||
if provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else if apiKey = providerKey(*provider); apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
os.Exit(1)
|
||||
} else if apiKey = providerKey(provider); apiKey == "" {
|
||||
return fmt.Errorf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env", provider)
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mSupport desk (provider: %s)\033[0m\n\n", *provider)
|
||||
fmt.Printf("\n\033[1mSupport desk (provider: %s)\033[0m\n\n", provider)
|
||||
|
||||
// Shared in-memory infrastructure so the demo runs in one process.
|
||||
reg := registry.NewMemoryRegistry()
|
||||
br := broker.NewMemoryBroker()
|
||||
if err := br.Connect(); err != nil {
|
||||
fmt.Println("broker connect:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("broker connect: %w", err)
|
||||
}
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
|
||||
// Services.
|
||||
tickets := new(TicketService)
|
||||
notify := new(NotifyService)
|
||||
var services []service.Service
|
||||
for name, h := range map[string]any{"customers": new(CustomerService), "tickets": tickets, "notify": notify} {
|
||||
svc := service.New(service.Name(name), service.Registry(reg), service.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl), service.HandleSignal(false))
|
||||
_ = svc.Handle(h)
|
||||
services = append(services, svc)
|
||||
go svc.Run()
|
||||
}
|
||||
defer func() {
|
||||
for _, svc := range services {
|
||||
_ = svc.Server().Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// The support agent manages the three services. The approval gate is
|
||||
// the human-in-the-loop: it can read and triage freely, but emailing a
|
||||
@@ -250,10 +252,11 @@ func main() {
|
||||
// it for a person or a policy; here we approve and log.
|
||||
support := agent.New(
|
||||
agent.Name("support"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("customers", "tickets", "notify"),
|
||||
agent.Prompt("You are a support agent. For each ticket, look up the customer, set an "+
|
||||
"appropriate priority, and reply to them. Escalate billing issues."),
|
||||
agent.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if strings.Contains(tool, "Send") {
|
||||
fmt.Printf(" \033[33m▣ approval gate\033[0m %s(%v) — approved\n", tool, input["to"])
|
||||
@@ -275,8 +278,7 @@ func main() {
|
||||
flow.Prompt("A new support ticket arrived: {{.Data}}. Handle it."),
|
||||
)
|
||||
if err := intake.Register(reg, br, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
}
|
||||
defer intake.Stop()
|
||||
|
||||
@@ -287,8 +289,7 @@ func main() {
|
||||
fmt.Println("\033[1m> event:\033[0m events.ticket.created", string(body))
|
||||
fmt.Println()
|
||||
if err := br.Publish("events.ticket.created", &broker.Message{Body: body}); err != nil {
|
||||
fmt.Println("publish:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the agent to act.
|
||||
@@ -305,7 +306,18 @@ func main() {
|
||||
}
|
||||
if notify.sent >= 1 {
|
||||
fmt.Println("\n\033[32m✓ ticket triaged and the customer was replied to — triggered by an event\033[0m")
|
||||
} else {
|
||||
fmt.Println("\n\033[31m✗ the agent did not complete the triage\033[0m")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("\n\033[31m✗ the agent did not complete the triage\033[0m")
|
||||
return fmt.Errorf("support agent did not complete triage")
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
|
||||
if err := runSupport(*provider); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunSupportMockSmoke(t *testing.T) {
|
||||
if err := runSupport("mock"); err != nil {
|
||||
t.Fatalf("support example failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroToHeroReadmeDocumentsLifecycle(t *testing.T) {
|
||||
b, err := os.ReadFile("README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("read README.md: %v", err)
|
||||
}
|
||||
doc := string(b)
|
||||
for _, want := range []string{
|
||||
"Scaffold services",
|
||||
"Run the harness",
|
||||
"Chat through an agent",
|
||||
"Inspect the workflow",
|
||||
"go test ./examples/support",
|
||||
} {
|
||||
if !strings.Contains(doc, want) {
|
||||
t.Fatalf("README.md missing zero-to-hero step %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// AnalyzeOptions configures Analyze.
|
||||
type AnalyzeOptions struct {
|
||||
// MaxFeedbackSamples bounds the number of representative grader feedback
|
||||
// strings retained per candidate. Values <= 0 use a small default.
|
||||
MaxFeedbackSamples int
|
||||
}
|
||||
|
||||
// AnalyzeOption configures Analyze.
|
||||
type AnalyzeOption func(*AnalyzeOptions)
|
||||
|
||||
// AnalyzeMaxFeedbackSamples sets how many grader feedback examples are kept for
|
||||
// each candidate in the report.
|
||||
func AnalyzeMaxFeedbackSamples(n int) AnalyzeOption {
|
||||
return func(o *AnalyzeOptions) { o.MaxFeedbackSamples = n }
|
||||
}
|
||||
|
||||
// Report is the machine-readable output of Analyze. Candidates are ordered from
|
||||
// worst to best so an agent, CLI, or human can pick the first improvement to try.
|
||||
type Report struct {
|
||||
Candidates []Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// Candidate identifies one underperforming flow step and the trace evidence that
|
||||
// made it worth improving.
|
||||
type Candidate struct {
|
||||
Step string `json:"step"`
|
||||
Metric string `json:"metric"`
|
||||
Score float64 `json:"score"`
|
||||
Runs int `json:"runs"`
|
||||
Failures int `json:"failures"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
AverageRetries float64 `json:"average_retries"`
|
||||
P50Latency time.Duration `json:"p50_latency"`
|
||||
P95Latency time.Duration `json:"p95_latency"`
|
||||
SampleFeedback []string `json:"sample_feedback,omitempty"`
|
||||
RunIDs []string `json:"run_ids,omitempty"`
|
||||
}
|
||||
|
||||
// Analyze aggregates a bounded window of persisted flow runs and returns ranked
|
||||
// hill-climbing candidates. It uses the same Run records read by Checkpoint.List:
|
||||
// failed verification fields in step results drive pass-rate and feedback, step
|
||||
// status drives error rate, and retry attempts contribute retry pressure. An
|
||||
// empty window returns an empty report.
|
||||
func Analyze(runs []Run, opts ...AnalyzeOption) Report {
|
||||
o := AnalyzeOptions{MaxFeedbackSamples: 3}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
if o.MaxFeedbackSamples <= 0 {
|
||||
o.MaxFeedbackSamples = 3
|
||||
}
|
||||
|
||||
stats := map[string]*stepStats{}
|
||||
for _, run := range runs {
|
||||
for _, step := range run.Steps {
|
||||
if step.Name == "" {
|
||||
continue
|
||||
}
|
||||
s := stats[step.Name]
|
||||
if s == nil {
|
||||
s = &stepStats{}
|
||||
stats[step.Name] = s
|
||||
}
|
||||
s.runs++
|
||||
s.runIDs = appendUnique(s.runIDs, run.ID)
|
||||
if step.Attempts > 1 {
|
||||
s.retries += step.Attempts - 1
|
||||
}
|
||||
if step.Status == "failed" || step.Error != "" {
|
||||
s.errors++
|
||||
}
|
||||
if len(run.Steps) > 0 && !run.Started.IsZero() && !run.Updated.IsZero() {
|
||||
s.latencies = append(s.latencies, run.Updated.Sub(run.Started)/time.Duration(len(run.Steps)))
|
||||
}
|
||||
passed, feedback, ok := verificationFields(step.Result)
|
||||
if ok {
|
||||
s.graded++
|
||||
if !passed {
|
||||
s.gradeFailures++
|
||||
if feedback != "" && len(s.feedback) < o.MaxFeedbackSamples {
|
||||
s.feedback = append(s.feedback, feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report := Report{}
|
||||
for step, s := range stats {
|
||||
if s.runs == 0 {
|
||||
continue
|
||||
}
|
||||
failures := s.errors + s.gradeFailures
|
||||
passRate := 1.0
|
||||
if s.graded > 0 {
|
||||
passRate = float64(s.graded-s.gradeFailures) / float64(s.graded)
|
||||
} else if s.errors > 0 {
|
||||
passRate = float64(s.runs-s.errors) / float64(s.runs)
|
||||
}
|
||||
errorRate := float64(s.errors) / float64(s.runs)
|
||||
avgRetries := float64(s.retries) / float64(s.runs)
|
||||
score := float64(s.gradeFailures)*3 + float64(s.errors)*2 + avgRetries
|
||||
metric := "pass_rate"
|
||||
if s.gradeFailures == 0 && s.errors > 0 {
|
||||
metric = "error_rate"
|
||||
} else if s.gradeFailures == 0 && s.errors == 0 && s.retries > 0 {
|
||||
metric = "retry_count"
|
||||
}
|
||||
report.Candidates = append(report.Candidates, Candidate{
|
||||
Step: step, Metric: metric, Score: score, Runs: s.runs, Failures: failures,
|
||||
PassRate: passRate, ErrorRate: errorRate, AverageRetries: avgRetries,
|
||||
P50Latency: percentile(s.latencies, 0.50), P95Latency: percentile(s.latencies, 0.95),
|
||||
SampleFeedback: append([]string(nil), s.feedback...), RunIDs: append([]string(nil), s.runIDs...),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(report.Candidates, func(i, j int) bool {
|
||||
a, b := report.Candidates[i], report.Candidates[j]
|
||||
if a.Score == b.Score {
|
||||
return a.Step < b.Step
|
||||
}
|
||||
return a.Score > b.Score
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
type stepStats struct {
|
||||
runs, graded, gradeFailures, errors, retries int
|
||||
feedback, runIDs []string
|
||||
latencies []time.Duration
|
||||
}
|
||||
|
||||
// PromptOptimizer proposes prompt improvements for a candidate without mutating
|
||||
// the source flow. Applying the returned prompt stays explicitly gated by the caller.
|
||||
type PromptOptimizer struct{ model ai.Model }
|
||||
|
||||
// LLMOptimizer returns an optimizer that asks model to revise prompts for
|
||||
// Analyze candidates. The model is injected so tests and callers can use mocks.
|
||||
func LLMOptimizer(model ai.Model) *PromptOptimizer { return &PromptOptimizer{model: model} }
|
||||
|
||||
// OptimizePrompt asks the model for a revised prompt for candidate using the
|
||||
// current prompt and trace feedback. It returns only the proposal; it never
|
||||
// modifies a Flow, Step, or Checkpoint.
|
||||
func (o *PromptOptimizer) OptimizePrompt(ctx context.Context, candidate Candidate, currentPrompt string) (string, error) {
|
||||
if o == nil || o.model == nil {
|
||||
return "", fmt.Errorf("flow: LLMOptimizer requires a model")
|
||||
}
|
||||
prompt := fmt.Sprintf("Revise this workflow step prompt to improve the failing step.\nStep: %s\nMetric: %s\nScore: %.2f\nFeedback:\n- %s\n\nCurrent prompt:\n%s\n\nReturn only the revised prompt.", candidate.Step, candidate.Metric, candidate.Score, strings.Join(candidate.SampleFeedback, "\n- "), currentPrompt)
|
||||
resp, err := o.model.Generate(ctx, &ai.Request{Prompt: prompt})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
proposal := strings.TrimSpace(resp.Answer)
|
||||
if proposal == "" {
|
||||
proposal = strings.TrimSpace(resp.Reply)
|
||||
}
|
||||
if proposal == "" {
|
||||
return "", fmt.Errorf("flow: LLMOptimizer returned an empty prompt")
|
||||
}
|
||||
return proposal, nil
|
||||
}
|
||||
|
||||
func verificationFields(result string) (bool, string, bool) {
|
||||
if result == "" {
|
||||
return false, "", false
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &obj); err != nil {
|
||||
return false, "", false
|
||||
}
|
||||
v, ok := obj["verification_passed"].(bool)
|
||||
if !ok {
|
||||
return false, "", false
|
||||
}
|
||||
fb, _ := obj["verification_feedback"].(string)
|
||||
return v, fb, true
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
if value == "" {
|
||||
return values
|
||||
}
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func percentile(values []time.Duration, p float64) time.Duration {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := append([]time.Duration(nil), values...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
idx := int(float64(len(sorted)-1) * p)
|
||||
return sorted[idx]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestAnalyzeRanksFailedGraderStepAbovePassingStep(t *testing.T) {
|
||||
now := time.Now()
|
||||
runs := []Run{
|
||||
{ID: "run-1", Started: now, Updated: now.Add(time.Second), Steps: []StepRecord{
|
||||
{Name: "draft", Status: "done", Attempts: 2, Result: `{"verification_passed":false,"verification_feedback":"cite sources"}`},
|
||||
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
|
||||
}},
|
||||
{ID: "run-2", Started: now, Updated: now.Add(2 * time.Second), Steps: []StepRecord{
|
||||
{Name: "draft", Status: "done", Attempts: 1, Result: `{"verification_passed":false,"verification_feedback":"too vague"}`},
|
||||
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
|
||||
}},
|
||||
}
|
||||
|
||||
report := Analyze(runs)
|
||||
if len(report.Candidates) != 2 {
|
||||
t.Fatalf("Analyze returned %d candidates, want 2", len(report.Candidates))
|
||||
}
|
||||
if got := report.Candidates[0].Step; got != "draft" {
|
||||
t.Fatalf("top candidate = %q, want draft", got)
|
||||
}
|
||||
if report.Candidates[0].PassRate != 0 {
|
||||
t.Fatalf("draft pass rate = %v, want 0", report.Candidates[0].PassRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeCarriesFeedbackSamplesAndRunIDs(t *testing.T) {
|
||||
report := Analyze([]Run{{ID: "run-9", Steps: []StepRecord{{
|
||||
Name: "grade", Status: "done", Attempts: 3,
|
||||
Result: `{"verification_passed":false,"verification_feedback":"include totals"}`,
|
||||
}}}})
|
||||
if len(report.Candidates) != 1 {
|
||||
t.Fatalf("candidates = %d, want 1", len(report.Candidates))
|
||||
}
|
||||
c := report.Candidates[0]
|
||||
if len(c.SampleFeedback) != 1 || c.SampleFeedback[0] != "include totals" {
|
||||
t.Fatalf("feedback = %#v, want include totals", c.SampleFeedback)
|
||||
}
|
||||
if len(c.RunIDs) != 1 || c.RunIDs[0] != "run-9" {
|
||||
t.Fatalf("run ids = %#v, want run-9", c.RunIDs)
|
||||
}
|
||||
if c.AverageRetries != 2 {
|
||||
t.Fatalf("average retries = %v, want 2", c.AverageRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEmptyWindowReturnsEmptyReport(t *testing.T) {
|
||||
if got := Analyze(nil); len(got.Candidates) != 0 {
|
||||
t.Fatalf("empty Analyze candidates = %d, want 0", len(got.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMOptimizerReturnsProposalWithoutMutatingFlow(t *testing.T) {
|
||||
f := New("optimize", Prompt("original prompt"))
|
||||
before := f.opts.Prompt
|
||||
optimizer := LLMOptimizer(&optimizerModel{reply: "revised prompt"})
|
||||
proposal, err := optimizer.OptimizePrompt(context.Background(), Candidate{Step: "draft", Metric: "pass_rate", SampleFeedback: []string{"cite sources"}}, before)
|
||||
if err != nil {
|
||||
t.Fatalf("OptimizePrompt returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(proposal, "revised") {
|
||||
t.Fatalf("proposal = %q, want revised prompt", proposal)
|
||||
}
|
||||
if f.opts.Prompt != before {
|
||||
t.Fatalf("flow prompt mutated to %q, want %q", f.opts.Prompt, before)
|
||||
}
|
||||
}
|
||||
|
||||
type optimizerModel struct{ reply string }
|
||||
|
||||
func (m *optimizerModel) Init(...ai.Option) error { return nil }
|
||||
func (m *optimizerModel) Options() ai.Options { return ai.Options{} }
|
||||
func (m *optimizerModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
|
||||
return &ai.Response{Reply: m.reply}, nil
|
||||
}
|
||||
func (m *optimizerModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
func (m *optimizerModel) String() string { return "optimizer" }
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
codecbytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// fakeClient embeds the default client (so NewRequest works) and
|
||||
@@ -65,3 +66,65 @@ func TestExecuteDispatchesToAgent(t *testing.T) {
|
||||
t.Errorf("rendered prompt = %q, want %q", results[0].Prompt, "welcome bob")
|
||||
}
|
||||
}
|
||||
|
||||
// A caller-owned schedule can trigger an agent workflow without a human chat
|
||||
// prompt and still leave the normal flow run metadata behind for inspection.
|
||||
func TestScheduledAgentRunHarnessContract(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "scheduled-contract")
|
||||
f := New("scheduled-contract",
|
||||
Trigger("schedule.daily"),
|
||||
WithCheckpoint(cp),
|
||||
Steps(Step{Name: "summarize", Run: Dispatch("ops-agent")}),
|
||||
)
|
||||
|
||||
var parentID string
|
||||
f.client = &fakeClient{
|
||||
Client: client.DefaultClient,
|
||||
callFn: func(req client.Request, rsp interface{}) error {
|
||||
if req.Service() != "ops-agent" || req.Endpoint() != "Agent.Chat" {
|
||||
t.Fatalf("dispatched to %s.%s, want ops-agent.Agent.Chat", req.Service(), req.Endpoint())
|
||||
}
|
||||
reqFrame := req.Body().(*codecbytes.Frame)
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
|
||||
t.Fatalf("request body: %v", err)
|
||||
}
|
||||
parentID = body["parent_id"]
|
||||
if body["message"] != "run unattended daily ops review" {
|
||||
t.Fatalf("message = %q, want scheduled payload", body["message"])
|
||||
}
|
||||
frame := rsp.(*codecbytes.Frame)
|
||||
frame.Data = []byte(`{"reply":"review queued","agent":"ops-agent","parent_id":"` + parentID + `"}`)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
if err := Scheduled(f, "run unattended daily ops review").Tick(ctx); err != nil {
|
||||
t.Fatalf("scheduled tick: %v", err)
|
||||
}
|
||||
if parentID == "" {
|
||||
t.Fatal("dispatch did not receive the scheduled flow run id as parent_id")
|
||||
}
|
||||
|
||||
runs, err := cp.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list scheduled runs: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("got %d runs, want 1", len(runs))
|
||||
}
|
||||
run := runs[0]
|
||||
if run.ID != parentID {
|
||||
t.Fatalf("run ID = %q, parent_id = %q", run.ID, parentID)
|
||||
}
|
||||
if run.Flow != "scheduled-contract" || run.Status != "done" {
|
||||
t.Fatalf("run = %+v, want scheduled-contract done", run)
|
||||
}
|
||||
if got := run.State.String(); got != "review queued" {
|
||||
t.Fatalf("run result = %q, want agent reply", got)
|
||||
}
|
||||
if len(run.Steps) != 1 || run.Steps[0].Name != "summarize" || run.Steps[0].Status != "done" {
|
||||
t.Fatalf("steps = %+v, want summarize done", run.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package flow_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
func ExampleVerify() {
|
||||
generate := func(_ context.Context, in flow.State) (flow.State, error) {
|
||||
if strings.Contains(in.String(), "feedback") {
|
||||
in.Data = []byte(`{"answer":"include a source"}`)
|
||||
return in, nil
|
||||
}
|
||||
in.Data = []byte(`{"answer":"draft"}`)
|
||||
return in, nil
|
||||
}
|
||||
grader := func(_ context.Context, out flow.State) (bool, string, error) {
|
||||
return strings.Contains(out.String(), "source"), "add a source", nil
|
||||
}
|
||||
|
||||
out, _ := flow.Verify(generate, grader, flow.VerifyMaxAttempts(2))(context.Background(), flow.State{})
|
||||
fmt.Println(strings.Contains(out.String(), `"verification_passed":true`))
|
||||
// Output: true
|
||||
}
|
||||
|
||||
func ExampleAnalyze() {
|
||||
runs := []flow.Run{{
|
||||
ID: "run-1",
|
||||
Steps: []flow.StepRecord{{
|
||||
Name: "draft",
|
||||
Status: "done",
|
||||
Result: `{"verification_passed":false,"verification_feedback":"add a source"}`,
|
||||
}},
|
||||
}}
|
||||
|
||||
report := flow.Analyze(runs)
|
||||
fmt.Println(report.Candidates[0].Step)
|
||||
fmt.Println(report.Candidates[0].SampleFeedback[0])
|
||||
// Output:
|
||||
// draft
|
||||
// add a source
|
||||
}
|
||||
+10
-2
@@ -76,6 +76,7 @@ type Result struct {
|
||||
Answer string `json:"answer,omitempty"`
|
||||
ToolCalls []string `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorKind string `json:"error_kind,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Duration float64 `json:"duration_seconds"`
|
||||
}
|
||||
@@ -141,7 +142,8 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
|
||||
if f.opts.TriggerTopic != "" {
|
||||
sub, err := br.Subscribe(f.opts.TriggerTopic, func(p broker.Event) error {
|
||||
data := string(p.Message().Body)
|
||||
if err := f.Execute(context.Background(), data); err != nil {
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
|
||||
if err := f.Execute(ctx, data); err != nil {
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
|
||||
}
|
||||
return nil
|
||||
@@ -222,7 +224,10 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.RunID = runID
|
||||
info.Flow = f.name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
@@ -246,6 +251,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
result.Duration = time.Since(start).Seconds()
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.ErrorKind = string(ai.ClassifyError(err))
|
||||
f.record(result)
|
||||
return err
|
||||
}
|
||||
@@ -261,6 +267,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Seconds()
|
||||
result.Error = err.Error()
|
||||
result.ErrorKind = string(ai.ClassifyError(err))
|
||||
f.record(result)
|
||||
return fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
@@ -274,6 +281,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.ErrorKind = string(ai.ClassifyError(err))
|
||||
f.record(result)
|
||||
return err
|
||||
}
|
||||
|
||||
+45
-14
@@ -16,13 +16,18 @@ const (
|
||||
spanNameFlowRun = "flow.run"
|
||||
spanNameFlowStep = "flow.step"
|
||||
|
||||
AttrFlowRunID = "flow.run.id"
|
||||
AttrFlowParentID = "flow.run.parent_id"
|
||||
AttrFlowName = "flow.name"
|
||||
AttrFlowStepName = "flow.step.name"
|
||||
AttrFlowStatus = "flow.status"
|
||||
AttrFlowAttempts = "flow.step.attempts"
|
||||
AttrFlowLatencyMS = "flow.latency_ms"
|
||||
AttrFlowRunID = "flow.run.id"
|
||||
AttrFlowParentID = "flow.run.parent_id"
|
||||
AttrFlowName = "flow.name"
|
||||
AttrFlowStepName = "flow.step.name"
|
||||
AttrFlowStatus = "flow.status"
|
||||
AttrFlowAttempts = "flow.step.attempts"
|
||||
AttrFlowLatencyMS = "flow.latency_ms"
|
||||
AttrFlowErrorKind = "flow.error.kind"
|
||||
AttrFlowVerificationStatus = "flow.verification.status"
|
||||
AttrFlowVerificationNote = "flow.verification.note"
|
||||
AttrFlowDispatch = "flow.dispatch"
|
||||
AttrFlowTrigger = "flow.trigger"
|
||||
)
|
||||
|
||||
func (f *Flow) tracer() trace.Tracer {
|
||||
@@ -33,12 +38,15 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
|
||||
if f.opts.TraceProvider == nil {
|
||||
return ctx, func(Run, error) {}
|
||||
}
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrFlowRunID, run.ID),
|
||||
attribute.String(AttrFlowParentID, run.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStatus, run.Status),
|
||||
))
|
||||
}
|
||||
attrs = appendRunInfoDispatch(attrs, info)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
|
||||
start := time.Now()
|
||||
return ctx, func(done Run, err error) {
|
||||
span.SetAttributes(
|
||||
@@ -47,6 +55,7 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
@@ -55,29 +64,51 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, error) {
|
||||
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, Verification, error) {
|
||||
if f.opts.TraceProvider == nil {
|
||||
return f.runStep(ctx, step, in)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrFlowRunID, info.RunID),
|
||||
attribute.String(AttrFlowParentID, info.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStepName, step.Name),
|
||||
))
|
||||
}
|
||||
attrs = appendRunInfoDispatch(attrs, info)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(attrs...))
|
||||
start := time.Now()
|
||||
out, attempts, err := f.runStep(ctx, step, in)
|
||||
out, attempts, verification, err := f.runStep(ctx, step, in)
|
||||
span.SetAttributes(
|
||||
attribute.Int(AttrFlowAttempts, attempts),
|
||||
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
|
||||
)
|
||||
if verification.Passed {
|
||||
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "passed"))
|
||||
}
|
||||
if verification.Feedback != "" {
|
||||
span.SetAttributes(attribute.String(AttrFlowVerificationNote, verification.Feedback))
|
||||
if !verification.Passed {
|
||||
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
return out, attempts, err
|
||||
return out, attempts, verification, err
|
||||
}
|
||||
|
||||
func appendRunInfoDispatch(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
|
||||
if info.Dispatch != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowDispatch, info.Dispatch))
|
||||
}
|
||||
if info.Trigger != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowTrigger, info.Trigger))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
@@ -74,3 +74,29 @@ func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
func withTestRunInfo(ctx context.Context, runID string) context.Context {
|
||||
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
|
||||
}
|
||||
|
||||
func TestScheduledFlowOpenTelemetryDispatchAttributes(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
|
||||
step := Step{Name: "summarize", Run: func(ctx context.Context, in State) (State, error) {
|
||||
in.Data = []byte("queued")
|
||||
return in, nil
|
||||
}}
|
||||
f := New("scheduled-observed", Trigger("schedule.daily"), WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "scheduled-observed")), TraceProvider(tp), Steps(step))
|
||||
if err := Scheduled(f, "daily ops review").Tick(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, span := range exp.GetSpans().Snapshots() {
|
||||
if span.Name() != spanNameFlowRun {
|
||||
continue
|
||||
}
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
if attrs[AttrFlowDispatch] != "schedule" || attrs[AttrFlowTrigger] != "schedule.daily" {
|
||||
t.Fatalf("scheduled run span dispatch attributes = %#v", attrs)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("flow run span not emitted")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// Schedule binds a flow to a recurring work item without introducing a
|
||||
// scheduler service. It is a small harness contract: callers own the clock,
|
||||
// Go Micro owns turning each tick into the same inspectable flow run used for
|
||||
// broker events and direct Execute calls.
|
||||
type Schedule struct {
|
||||
flow *Flow
|
||||
data string
|
||||
}
|
||||
|
||||
// Scheduled returns a deterministic scheduled-run harness for this flow.
|
||||
// Tests and event loops can call Tick directly; production processes can wire
|
||||
// the same contract to time.Ticker through RunEvery. Each tick calls Execute, so
|
||||
// checkpointed run history, parent/run metadata, cancellation, and inspection
|
||||
// stay on the normal flow surfaces.
|
||||
func Scheduled(f *Flow, data string) Schedule {
|
||||
return Schedule{flow: f, data: data}
|
||||
}
|
||||
|
||||
// Tick starts one scheduled run immediately and returns when that run finishes.
|
||||
func (s Schedule) Tick(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.Dispatch = "schedule"
|
||||
if info.Trigger == "" {
|
||||
info.Trigger = s.flow.opts.TriggerTopic
|
||||
}
|
||||
if info.Trigger == "" {
|
||||
info.Trigger = "schedule"
|
||||
}
|
||||
return s.flow.Execute(ai.WithRunInfo(ctx, info), s.data)
|
||||
}
|
||||
|
||||
// RunEvery drives scheduled runs from a ticker until ctx is canceled. It does
|
||||
// not persist schedule definitions or host a scheduler; it only adapts a caller
|
||||
// owned cadence to Tick.
|
||||
func (s Schedule) RunEvery(ctx context.Context, interval time.Duration) error {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := s.Tick(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-21
@@ -52,22 +52,53 @@ func (s State) String() string { return string(s.Data) }
|
||||
// returns the next state.
|
||||
type StepFunc func(ctx context.Context, in State) (State, error)
|
||||
|
||||
// Step is one unit of a flow — a named action with an optional retry
|
||||
// override. There is one Step kind; the action is the Run func, and the
|
||||
// Call/LLM/Agent helpers produce the common ones.
|
||||
// Verifier grades a step output before the flow advances. Returning
|
||||
// Passed=false converts the grade into a retryable VerificationError, so
|
||||
// the existing step retry/supervision path can feed Feedback into the next
|
||||
// attempt through ai.RunInfo.VerificationFeedback.
|
||||
type Verifier func(ctx context.Context, out State) (Verification, error)
|
||||
|
||||
// Verification is the verifier's deterministic grade for one step attempt.
|
||||
type Verification struct {
|
||||
Passed bool
|
||||
Feedback string
|
||||
}
|
||||
|
||||
// VerificationError reports a failed grade. It is returned from runStep so
|
||||
// existing retry, checkpoint, and trace paths handle verifier failures the
|
||||
// same way they handle step execution failures.
|
||||
type VerificationError struct {
|
||||
Step string
|
||||
Feedback string
|
||||
}
|
||||
|
||||
func (e *VerificationError) Error() string {
|
||||
if e.Feedback == "" {
|
||||
return fmt.Sprintf("flow: verification failed for step %q", e.Step)
|
||||
}
|
||||
return fmt.Sprintf("flow: verification failed for step %q: %s", e.Step, e.Feedback)
|
||||
}
|
||||
|
||||
// Step is one unit of a flow — a named action with optional retry and
|
||||
// verification hooks. There is one Step kind; the action is the Run func,
|
||||
// and the Call/LLM/Agent helpers produce the common ones.
|
||||
type Step struct {
|
||||
Name string
|
||||
Run StepFunc
|
||||
Retry int // per-step override of the flow's retry (0 = use the flow default)
|
||||
Name string
|
||||
Run StepFunc
|
||||
Retry int // per-step override of the flow's retry (0 = use the flow default)
|
||||
Verify Verifier // optional grade; failed grades retry the step with feedback in RunInfo
|
||||
}
|
||||
|
||||
// StepRecord is the recorded outcome of one step within a run.
|
||||
type StepRecord struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // pending | in_progress | done | failed
|
||||
Attempts int `json:"attempts"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // pending | in_progress | done | failed
|
||||
Attempts int `json:"attempts"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorKind string `json:"error_kind,omitempty"`
|
||||
VerificationStatus string `json:"verification_status,omitempty"` // passed | failed
|
||||
VerificationNote string `json:"verification_note,omitempty"`
|
||||
}
|
||||
|
||||
// Run is the persisted record of one flow execution — what a Checkpoint
|
||||
@@ -402,7 +433,12 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.RunID = run.ID
|
||||
info.ParentID = run.ParentID
|
||||
info.Agent = f.name
|
||||
info.Flow = f.name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
@@ -425,12 +461,14 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
return run, err
|
||||
}
|
||||
|
||||
out, attempts, err := f.runStepSpan(ctx, step, run.State)
|
||||
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
|
||||
run.Steps[i].Attempts = attempts
|
||||
applyVerificationRecord(&run.Steps[i], verification)
|
||||
if err != nil {
|
||||
spanErr = err
|
||||
run.Steps[i].Status = "failed"
|
||||
run.Steps[i].Error = err.Error()
|
||||
run.Steps[i].ErrorKind = string(ai.ClassifyError(err))
|
||||
run.Status = "failed"
|
||||
if saveErr := f.save(ctx, run); saveErr != nil {
|
||||
spanErr = saveErr
|
||||
@@ -474,40 +512,65 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
// runStep runs one step, retrying on error up to the resolved retry count.
|
||||
// A step with no Run function is a configuration error, and a canceled run
|
||||
// stops retrying immediately rather than burning the rest of its budget.
|
||||
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, error) {
|
||||
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Verification, error) {
|
||||
if step.Run == nil {
|
||||
return in, 0, fmt.Errorf("flow: step %q has no Run function", step.Name)
|
||||
return in, 0, Verification{}, fmt.Errorf("flow: step %q has no Run function", step.Name)
|
||||
}
|
||||
retries := f.opts.Retry
|
||||
if step.Retry > 0 {
|
||||
retries = step.Retry
|
||||
}
|
||||
var lastErr error
|
||||
var lastVerification Verification
|
||||
var feedback string
|
||||
for attempt := 1; attempt <= retries+1; attempt++ {
|
||||
// Stop the moment the run's context is canceled or its deadline
|
||||
// passes — a canceled run shouldn't keep retrying, and the context
|
||||
// error is surfaced so callers can detect cancellation upstream.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return in, attempt - 1, err
|
||||
return in, attempt - 1, lastVerification, err
|
||||
}
|
||||
attemptCtx := ctx
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
info.Step = step.Name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
info.VerificationFeedback = feedback
|
||||
attemptCtx = ai.WithRunInfo(ctx, info)
|
||||
}
|
||||
out, err := step.Run(attemptCtx, in)
|
||||
if err == nil && step.Verify != nil {
|
||||
lastVerification, err = step.Verify(attemptCtx, out)
|
||||
if err == nil && !lastVerification.Passed {
|
||||
err = &VerificationError{Step: step.Name, Feedback: lastVerification.Feedback}
|
||||
}
|
||||
}
|
||||
out, err := step.Run(ctx, in)
|
||||
if err == nil {
|
||||
return out, attempt, nil
|
||||
return out, attempt, lastVerification, nil
|
||||
}
|
||||
lastErr = err
|
||||
if verr, ok := err.(*VerificationError); ok {
|
||||
feedback = verr.Feedback
|
||||
}
|
||||
if attempt <= retries && f.opts.RetryBackoff > 0 {
|
||||
select {
|
||||
case <-time.After(f.opts.RetryBackoff):
|
||||
case <-ctx.Done():
|
||||
return in, attempt, ctx.Err()
|
||||
return in, attempt, lastVerification, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return in, retries + 1, lastErr
|
||||
return in, retries + 1, lastVerification, lastErr
|
||||
}
|
||||
|
||||
func applyVerificationRecord(record *StepRecord, verification Verification) {
|
||||
if verification.Passed {
|
||||
record.VerificationStatus = "passed"
|
||||
}
|
||||
if verification.Feedback != "" {
|
||||
record.VerificationNote = truncate(verification.Feedback, 200)
|
||||
if !verification.Passed {
|
||||
record.VerificationStatus = "failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) save(ctx context.Context, run Run) error {
|
||||
@@ -555,6 +618,7 @@ func resultFromRun(trigger string, run Run) Result {
|
||||
r.ToolCalls = append(r.ToolCalls, s.Name+":"+s.Status)
|
||||
if s.Error != "" {
|
||||
r.Error = s.Error
|
||||
r.ErrorKind = s.ErrorKind
|
||||
}
|
||||
}
|
||||
if run.Status == "done" {
|
||||
|
||||
@@ -548,3 +548,37 @@ func TestStateSetScan(t *testing.T) {
|
||||
t.Errorf("round-trip failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowFailureRecordsErrorKind(t *testing.T) {
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "failure-kind")
|
||||
f := New("failure-kind",
|
||||
WithCheckpoint(cp),
|
||||
Steps(Step{Name: "limited", Run: func(_ context.Context, in State) (State, error) {
|
||||
return in, errors.New("rate limit exceeded")
|
||||
}}),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "payload")
|
||||
if err == nil {
|
||||
t.Fatal("Execute error = nil, want failure")
|
||||
}
|
||||
|
||||
runs, listErr := cp.List(context.Background())
|
||||
if listErr != nil {
|
||||
t.Fatalf("List: %v", listErr)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("runs = %d, want 1", len(runs))
|
||||
}
|
||||
if got := runs[0].Steps[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
|
||||
t.Fatalf("step error kind = %q, want %q", got, ai.ErrorKindRateLimited)
|
||||
}
|
||||
|
||||
results := f.Results()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %d, want 1", len(results))
|
||||
}
|
||||
if got := results[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
|
||||
t.Fatalf("result error kind = %q, want %q", got, ai.ErrorKindRateLimited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestFlowStepVerificationRetriesWithFeedback(t *testing.T) {
|
||||
var attempts int
|
||||
var feedback []string
|
||||
step := Step{
|
||||
Name: "draft",
|
||||
Retry: 1,
|
||||
Run: func(ctx context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from verified step")
|
||||
}
|
||||
feedback = append(feedback, info.VerificationFeedback)
|
||||
if info.VerificationFeedback == "add evidence" {
|
||||
in.Data = []byte("answer with evidence")
|
||||
} else {
|
||||
in.Data = []byte("answer")
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
Verify: func(ctx context.Context, out State) (Verification, error) {
|
||||
if out.String() == "answer with evidence" {
|
||||
return Verification{Passed: true, Feedback: "meets rubric"}, nil
|
||||
}
|
||||
return Verification{Feedback: "add evidence"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "verified")
|
||||
f := New("verified", WithCheckpoint(cp), Steps(step))
|
||||
if err := f.Execute(context.Background(), "question"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
if len(feedback) != 2 || feedback[0] != "" || feedback[1] != "add evidence" {
|
||||
t.Fatalf("feedback = %#v, want empty then verifier feedback", feedback)
|
||||
}
|
||||
runs, err := cp.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("runs = %d, want 1", len(runs))
|
||||
}
|
||||
stepRecord := runs[0].Steps[0]
|
||||
if stepRecord.Status != "done" || stepRecord.Attempts != 2 || stepRecord.VerificationStatus != "passed" || stepRecord.VerificationNote != "meets rubric" {
|
||||
t.Fatalf("step record = %#v", stepRecord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepVerificationFailureIsCheckpointed(t *testing.T) {
|
||||
step := Step{
|
||||
Name: "grade",
|
||||
Run: func(ctx context.Context, in State) (State, error) {
|
||||
in.Data = []byte("bad")
|
||||
return in, nil
|
||||
},
|
||||
Verify: func(ctx context.Context, out State) (Verification, error) {
|
||||
return Verification{Feedback: "missing citation"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "verified-fail")
|
||||
f := New("verified-fail", WithCheckpoint(cp), Steps(step))
|
||||
err := f.Execute(context.Background(), "question")
|
||||
if err == nil {
|
||||
t.Fatal("Execute succeeded, want verification failure")
|
||||
}
|
||||
var verr *VerificationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("error = %T %v, want VerificationError", err, err)
|
||||
}
|
||||
if verr.Feedback != "missing citation" {
|
||||
t.Fatalf("feedback = %q, want missing citation", verr.Feedback)
|
||||
}
|
||||
runs, listErr := cp.List(context.Background())
|
||||
if listErr != nil {
|
||||
t.Fatal(listErr)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("runs = %d, want 1", len(runs))
|
||||
}
|
||||
stepRecord := runs[0].Steps[0]
|
||||
if runs[0].Status != "failed" || stepRecord.VerificationStatus != "failed" || stepRecord.VerificationNote != "missing citation" {
|
||||
t.Fatalf("run = %#v step = %#v", runs[0], stepRecord)
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// Grader checks a step output against a rubric. It returns pass=true when the
|
||||
// output is acceptable; otherwise feedback should explain what the next attempt
|
||||
// should fix.
|
||||
type Grader func(ctx context.Context, out State) (pass bool, feedback string, err error)
|
||||
|
||||
// VerifyOptions configure Verify.
|
||||
type VerifyOptions struct {
|
||||
// MaxAttempts bounds how many times the body can run. Default 2.
|
||||
MaxAttempts int
|
||||
// Backoff waits between failed grades. Zero means retry immediately.
|
||||
Backoff time.Duration
|
||||
// FeedbackField is the JSON field used to thread grader feedback into the
|
||||
// next attempt's input. Default "feedback".
|
||||
FeedbackField string
|
||||
}
|
||||
|
||||
// VerifyOption configures Verify.
|
||||
type VerifyOption func(*VerifyOptions)
|
||||
|
||||
// VerifyMaxAttempts sets the total attempt budget for Verify. Values <= 0 use
|
||||
// the default of 2.
|
||||
func VerifyMaxAttempts(n int) VerifyOption { return func(o *VerifyOptions) { o.MaxAttempts = n } }
|
||||
|
||||
// VerifyBackoff sets the delay between failed verification attempts.
|
||||
func VerifyBackoff(d time.Duration) VerifyOption { return func(o *VerifyOptions) { o.Backoff = d } }
|
||||
|
||||
// VerifyFeedbackField sets the JSON field used to pass grader feedback to the
|
||||
// next body attempt. Empty values use "feedback".
|
||||
func VerifyFeedbackField(field string) VerifyOption {
|
||||
return func(o *VerifyOptions) { o.FeedbackField = field }
|
||||
}
|
||||
|
||||
// Verify runs body, grades its output, and retries with grader feedback threaded
|
||||
// into the next input until the grader passes or MaxAttempts is exhausted. It is
|
||||
// a StepFunc, so it composes directly as Step.Run with Loop, LLM, Call, Agent, or
|
||||
// any code-defined step.
|
||||
//
|
||||
// On a failed grade, Verify adds the feedback to the next attempt's input as a
|
||||
// JSON field named "feedback" (or VerifyFeedbackField). When all attempts fail,
|
||||
// it returns the last output without error, annotated with verification fields so
|
||||
// the run can keep the bounded failure outcome in its state:
|
||||
// "verification_passed": false, "verification_feedback", and
|
||||
// "verification_attempts".
|
||||
func Verify(body StepFunc, grader Grader, opts ...VerifyOption) StepFunc {
|
||||
o := VerifyOptions{MaxAttempts: 2, FeedbackField: "feedback"}
|
||||
for _, op := range opts {
|
||||
op(&o)
|
||||
}
|
||||
if o.MaxAttempts <= 0 {
|
||||
o.MaxAttempts = 2
|
||||
}
|
||||
if o.FeedbackField == "" {
|
||||
o.FeedbackField = "feedback"
|
||||
}
|
||||
return func(ctx context.Context, in State) (State, error) {
|
||||
if body == nil {
|
||||
return in, fmt.Errorf("flow: Verify requires a body step")
|
||||
}
|
||||
if grader == nil {
|
||||
return in, fmt.Errorf("flow: Verify requires a grader")
|
||||
}
|
||||
cur := in
|
||||
last := in
|
||||
feedback := ""
|
||||
for attempt := 1; attempt <= o.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return last, err
|
||||
}
|
||||
if feedback != "" {
|
||||
var err error
|
||||
cur, err = stateWithField(cur, o.FeedbackField, feedback)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
}
|
||||
out, err := body(ctx, cur)
|
||||
if err != nil {
|
||||
return last, fmt.Errorf("verify attempt %d: %w", attempt, err)
|
||||
}
|
||||
last = out
|
||||
pass, fb, err := grader(ctx, out)
|
||||
if err != nil {
|
||||
return last, fmt.Errorf("verify grade attempt %d: %w", attempt, err)
|
||||
}
|
||||
if pass {
|
||||
return stateWithVerification(out, true, fb, attempt)
|
||||
}
|
||||
feedback = fb
|
||||
cur = in
|
||||
if attempt < o.MaxAttempts && o.Backoff > 0 {
|
||||
select {
|
||||
case <-time.After(o.Backoff):
|
||||
case <-ctx.Done():
|
||||
return last, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return stateWithVerification(last, false, feedback, o.MaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// LLMGrader returns a grader that asks the flow model to judge the latest output
|
||||
// against rubric. The model should answer with pass/fail plus short feedback.
|
||||
// It reuses the flow's configured model, so it must run inside a flow.
|
||||
func LLMGrader(rubric string) Grader {
|
||||
return func(ctx context.Context, out State) (bool, string, error) {
|
||||
d := depsFrom(ctx)
|
||||
if d == nil || d.model == nil {
|
||||
return false, "", fmt.Errorf("flow: LLMGrader requires a flow model (set Provider/APIKey)")
|
||||
}
|
||||
prompt := fmt.Sprintf("Grade the latest result against this rubric:\n%s\n\nLatest result:\n%s\n\nAnswer with PASS or FAIL on the first line, followed by one short feedback sentence.", rubric, out.String())
|
||||
resp, err := d.model.Generate(ctx, &ai.Request{Prompt: prompt})
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
reply := resp.Answer
|
||||
if reply == "" {
|
||||
reply = resp.Reply
|
||||
}
|
||||
return parseGrade(reply)
|
||||
}
|
||||
}
|
||||
|
||||
func parseGrade(reply string) (bool, string, error) {
|
||||
text := strings.TrimSpace(reply)
|
||||
if text == "" {
|
||||
return false, "", fmt.Errorf("flow: LLMGrader returned an empty grade")
|
||||
}
|
||||
lines := strings.SplitN(text, "\n", 2)
|
||||
first := strings.ToLower(strings.TrimSpace(lines[0]))
|
||||
feedback := ""
|
||||
if len(lines) > 1 {
|
||||
feedback = strings.TrimSpace(lines[1])
|
||||
}
|
||||
pass := strings.HasPrefix(first, "pass") || isAffirmative(first)
|
||||
if !pass && feedback == "" {
|
||||
feedback = text
|
||||
}
|
||||
return pass, feedback, nil
|
||||
}
|
||||
|
||||
func stateWithField(s State, field, value string) (State, error) {
|
||||
var obj map[string]any
|
||||
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
|
||||
obj[field] = value
|
||||
return stateWithObject(s, obj)
|
||||
}
|
||||
obj = map[string]any{field: value}
|
||||
if len(s.Data) > 0 {
|
||||
obj["data"] = s.String()
|
||||
}
|
||||
return stateWithObject(s, obj)
|
||||
}
|
||||
|
||||
func stateWithVerification(s State, passed bool, feedback string, attempts int) (State, error) {
|
||||
var obj map[string]any
|
||||
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
|
||||
obj["verification_passed"] = passed
|
||||
obj["verification_feedback"] = feedback
|
||||
obj["verification_attempts"] = attempts
|
||||
return stateWithObject(s, obj)
|
||||
}
|
||||
obj = map[string]any{
|
||||
"data": s.String(),
|
||||
"verification_passed": passed,
|
||||
"verification_feedback": feedback,
|
||||
"verification_attempts": attempts,
|
||||
}
|
||||
return stateWithObject(s, obj)
|
||||
}
|
||||
|
||||
func stateWithObject(s State, obj map[string]any) (State, error) {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Data = b
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyPassesFirstTry(t *testing.T) {
|
||||
attempts := 0
|
||||
step := Verify(func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
in.Data = []byte(`{"answer":"ok"}`)
|
||||
return in, nil
|
||||
}, func(context.Context, State) (bool, string, error) {
|
||||
return true, "looks good", nil
|
||||
}, VerifyMaxAttempts(3))
|
||||
|
||||
out, err := step(context.Background(), State{})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned error: %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("body attempts = %d, want 1", attempts)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := out.Scan(&got); err != nil {
|
||||
t.Fatalf("scan output: %v", err)
|
||||
}
|
||||
if got["verification_passed"] != true {
|
||||
t.Fatalf("verification_passed = %v, want true", got["verification_passed"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRetriesWithFeedback(t *testing.T) {
|
||||
attempts := 0
|
||||
var secondInput map[string]string
|
||||
step := Verify(func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
if attempts == 2 {
|
||||
if err := in.Scan(&secondInput); err != nil {
|
||||
t.Fatalf("scan second input: %v", err)
|
||||
}
|
||||
}
|
||||
in.Data = []byte(`{"answer":"draft"}`)
|
||||
return in, nil
|
||||
}, func(_ context.Context, _ State) (bool, string, error) {
|
||||
return attempts >= 2, "include citations", nil
|
||||
}, VerifyMaxAttempts(3))
|
||||
|
||||
out, err := step(context.Background(), State{Data: []byte(`{"topic":"agents"}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned error: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("body attempts = %d, want 2", attempts)
|
||||
}
|
||||
if secondInput["feedback"] != "include citations" {
|
||||
t.Fatalf("feedback = %q, want include citations", secondInput["feedback"])
|
||||
}
|
||||
if !strings.Contains(out.String(), `"verification_passed":true`) {
|
||||
t.Fatalf("output missing successful verification annotation: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExhaustsAttemptsReturnsLastOutput(t *testing.T) {
|
||||
attempts := 0
|
||||
step := Verify(func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
in.Data = []byte(`{"answer":"still wrong"}`)
|
||||
return in, nil
|
||||
}, func(context.Context, State) (bool, string, error) {
|
||||
return false, "try again", nil
|
||||
}, VerifyMaxAttempts(2))
|
||||
|
||||
out, err := step(context.Background(), State{})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned error: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("body attempts = %d, want 2", attempts)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := out.Scan(&got); err != nil {
|
||||
t.Fatalf("scan output: %v", err)
|
||||
}
|
||||
if got["verification_passed"] != false {
|
||||
t.Fatalf("verification_passed = %v, want false", got["verification_passed"])
|
||||
}
|
||||
if got["verification_feedback"] != "try again" {
|
||||
t.Fatalf("verification_feedback = %v, want try again", got["verification_feedback"])
|
||||
}
|
||||
if got["verification_attempts"] != float64(2) {
|
||||
t.Fatalf("verification_attempts = %v, want 2", got["verification_attempts"])
|
||||
}
|
||||
}
|
||||
+217
-24
@@ -21,13 +21,14 @@
|
||||
// Scope of this version: the JSON-RPC binding — `message/send`
|
||||
// (returns a completed Task), `message/stream` (SSE with the completed
|
||||
// Task event), `tasks/get`, multi-turn task continuation, push
|
||||
// notification delivery, and Agent Card discovery. `input-required` and
|
||||
// `tasks/resubscribe` are advertised as unsupported and are follow-ups.
|
||||
// notification delivery, input-required handoffs, `tasks/resubscribe`,
|
||||
// and Agent Card discovery.
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -140,8 +141,11 @@ func (g *Gateway) Handler() http.Handler {
|
||||
// Per-agent card (served at the agent's url and at its well-known path).
|
||||
mux.HandleFunc("GET /agents/{name}", g.handleCard)
|
||||
mux.HandleFunc("GET /agents/{name}/.well-known/agent.json", g.handleCard)
|
||||
mux.HandleFunc("GET /agents/{name}/skills/{skill}", g.handleSkillCard)
|
||||
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent.json", g.handleSkillCard)
|
||||
// Per-agent JSON-RPC endpoint.
|
||||
mux.HandleFunc("POST /agents/{name}", g.handleRPC)
|
||||
mux.HandleFunc("POST /agents/{name}/skills/{skill}", g.handleSkillRPC)
|
||||
// Top-level well-known: serve the single agent's card if there's
|
||||
// exactly one, otherwise point to the directory.
|
||||
mux.HandleFunc("GET /.well-known/agent.json", g.handleWellKnown)
|
||||
@@ -176,6 +180,8 @@ type Provider struct {
|
||||
type Capabilities struct {
|
||||
Streaming bool `json:"streaming"`
|
||||
PushNotifications bool `json:"pushNotifications"`
|
||||
TaskResubscribe bool `json:"taskResubscribe"`
|
||||
InputRequired bool `json:"inputRequired"`
|
||||
}
|
||||
|
||||
// Skill is a capability advertised on the Agent Card.
|
||||
@@ -236,9 +242,10 @@ type PushNotificationConfig struct {
|
||||
|
||||
// Task states (JSON-RPC binding wire values).
|
||||
const (
|
||||
stateCompleted = "completed"
|
||||
stateFailed = "failed"
|
||||
stateWorking = "working"
|
||||
stateCompleted = "completed"
|
||||
stateFailed = "failed"
|
||||
stateWorking = "working"
|
||||
stateInputRequired = "input-required"
|
||||
)
|
||||
|
||||
// JSON-RPC envelopes.
|
||||
@@ -337,23 +344,17 @@ func Card(name, url, description string, services []string) AgentCard {
|
||||
description = "Go Micro agent"
|
||||
}
|
||||
}
|
||||
skills := skillsFromServices(services)
|
||||
return AgentCard{
|
||||
Name: name,
|
||||
Description: description,
|
||||
URL: url,
|
||||
Version: "1.0.0",
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: Capabilities{Streaming: true, PushNotifications: true},
|
||||
// The agent converses over a single Chat endpoint; advertise that
|
||||
// as one skill, tagged with the services it manages.
|
||||
Name: name,
|
||||
Description: description,
|
||||
URL: url,
|
||||
Version: "1.0.0",
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: Capabilities{Streaming: true, PushNotifications: true, TaskResubscribe: true, InputRequired: true},
|
||||
DefaultInputModes: []string{"text/plain"},
|
||||
DefaultOutputModes: []string{"text/plain"},
|
||||
Skills: []Skill{{
|
||||
ID: "chat",
|
||||
Name: "Chat",
|
||||
Description: "Converse with the agent to operate its services.",
|
||||
Tags: services,
|
||||
}},
|
||||
Skills: skills,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +371,19 @@ func (g *Gateway) lookupCard(name string) (AgentCard, bool) {
|
||||
return g.card(name, meta), true
|
||||
}
|
||||
|
||||
func (g *Gateway) lookupSkillCard(name, skillID string) (AgentCard, Skill, bool) {
|
||||
card, ok := g.lookupCard(name)
|
||||
if !ok {
|
||||
return AgentCard{}, Skill{}, false
|
||||
}
|
||||
for _, skill := range card.Skills {
|
||||
if skill.ID == skillID {
|
||||
return card, skill, true
|
||||
}
|
||||
}
|
||||
return AgentCard{}, Skill{}, false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -392,6 +406,17 @@ func (g *Gateway) handleCard(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, card)
|
||||
}
|
||||
|
||||
func (g *Gateway) handleSkillCard(w http.ResponseWriter, r *http.Request) {
|
||||
card, skill, ok := g.lookupSkillCard(r.PathValue("name"), r.PathValue("skill"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
card.URL = g.opts.BaseURL + "/agents/" + r.PathValue("name") + "/skills/" + skill.ID
|
||||
card.Skills = []Skill{skill}
|
||||
writeJSON(w, http.StatusOK, card)
|
||||
}
|
||||
|
||||
func (g *Gateway) handleWellKnown(w http.ResponseWriter, r *http.Request) {
|
||||
cards, err := g.cards()
|
||||
if err != nil {
|
||||
@@ -420,6 +445,18 @@ func (g *Gateway) handleRPC(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Gateway) handleSkillRPC(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
_, skill, ok := g.lookupSkillCard(name, r.PathValue("skill"))
|
||||
if !ok {
|
||||
writeRPC(w, nil, nil, &rpcError{Code: errInvalidParams, Message: "unknown agent skill: " + name + "/" + r.PathValue("skill")})
|
||||
return
|
||||
}
|
||||
g.disp.serve(w, r, func(ctx context.Context, text string) (string, error) {
|
||||
return g.callAgent(ctx, name, skillPrompt(skill, text))
|
||||
})
|
||||
}
|
||||
|
||||
// dispatcher handles A2A JSON-RPC requests against an Invoke function and
|
||||
// retains recent tasks for tasks/get. It is shared by the gateway (one
|
||||
// per registry) and embedded agents (one per agent).
|
||||
@@ -427,11 +464,12 @@ type dispatcher struct {
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
pushConfigs map[string]PushNotificationConfig
|
||||
watchers map[string]map[chan *Task]struct{}
|
||||
order []string // task ids in insertion order, for bounded eviction
|
||||
}
|
||||
|
||||
func newDispatcher() *dispatcher {
|
||||
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}}
|
||||
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}, watchers: map[string]map[chan *Task]struct{}{}}
|
||||
}
|
||||
|
||||
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
|
||||
@@ -454,7 +492,7 @@ func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, inv
|
||||
d.send(requestContext(r.Context()), w, req, invoke)
|
||||
case "message/stream":
|
||||
if streamInvoke != nil {
|
||||
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke)
|
||||
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke, invoke)
|
||||
return
|
||||
}
|
||||
d.stream(requestContext(r.Context()), w, req, invoke)
|
||||
@@ -468,7 +506,7 @@ func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, inv
|
||||
// v1 tasks complete synchronously, so they're already terminal.
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errNotCancelable, Message: "task is not cancelable"})
|
||||
case "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "resubscribe is not supported"})
|
||||
d.resubscribe(requestContext(r.Context()), w, req)
|
||||
default:
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "method not found: " + req.Method})
|
||||
}
|
||||
@@ -503,7 +541,7 @@ func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcR
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke) {
|
||||
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke, fallback Invoke) {
|
||||
var p sendParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
@@ -516,6 +554,10 @@ func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, re
|
||||
}
|
||||
stream, err := invoke(ctx, text)
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrStreamingUnsupported) && fallback != nil {
|
||||
d.stream(ctx, w, req, fallback)
|
||||
return
|
||||
}
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInternal, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -557,6 +599,7 @@ func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, re
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
}
|
||||
@@ -577,6 +620,10 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
|
||||
if err != nil {
|
||||
reply = "error: " + err.Error()
|
||||
state = stateFailed
|
||||
if isInputRequiredError(err) {
|
||||
reply = err.Error()
|
||||
state = stateInputRequired
|
||||
}
|
||||
}
|
||||
task := d.taskFromReply(p.Message, reply, state)
|
||||
d.store(task)
|
||||
@@ -587,6 +634,49 @@ type getParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (d *dispatcher) resubscribe(ctx context.Context, w http.ResponseWriter, req rpcRequest) {
|
||||
var p getParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
return
|
||||
}
|
||||
ch, task, unsubscribe := d.subscribe(p.ID)
|
||||
if task == nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errTaskNotFound, Message: "task not found"})
|
||||
return
|
||||
}
|
||||
defer unsubscribe()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
writeEvent := func(t *Task) bool {
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: t})
|
||||
flush()
|
||||
return isTerminal(t.Status.State)
|
||||
}
|
||||
if writeEvent(task) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case next := <-ch:
|
||||
if writeEvent(next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) get(w http.ResponseWriter, req rpcRequest) {
|
||||
var p getParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" {
|
||||
@@ -672,18 +762,59 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
|
||||
|
||||
func (d *dispatcher) store(t *Task) {
|
||||
d.mu.Lock()
|
||||
_, exists := d.tasks[t.ID]
|
||||
d.tasks[t.ID] = t
|
||||
d.order = append(d.order, t.ID)
|
||||
if !exists {
|
||||
d.order = append(d.order, t.ID)
|
||||
}
|
||||
for len(d.order) > maxTasks {
|
||||
oldest := d.order[0]
|
||||
d.order = d.order[1:]
|
||||
delete(d.tasks, oldest)
|
||||
delete(d.pushConfigs, oldest)
|
||||
}
|
||||
for ch := range d.watchers[t.ID] {
|
||||
select {
|
||||
case ch <- t:
|
||||
default:
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
go d.deliverPush(t.ID, t)
|
||||
}
|
||||
|
||||
func (d *dispatcher) subscribe(taskID string) (chan *Task, *Task, func()) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
task := d.tasks[taskID]
|
||||
if task == nil {
|
||||
return nil, nil, func() {}
|
||||
}
|
||||
ch := make(chan *Task, 8)
|
||||
if d.watchers[taskID] == nil {
|
||||
d.watchers[taskID] = map[chan *Task]struct{}{}
|
||||
}
|
||||
d.watchers[taskID][ch] = struct{}{}
|
||||
return ch, task, func() {
|
||||
d.mu.Lock()
|
||||
delete(d.watchers[taskID], ch)
|
||||
if len(d.watchers[taskID]) == 0 {
|
||||
delete(d.watchers, taskID)
|
||||
}
|
||||
close(ch)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminal(state string) bool {
|
||||
return state == stateCompleted || state == stateFailed || state == stateInputRequired
|
||||
}
|
||||
|
||||
func isInputRequiredError(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "input-required") || strings.Contains(msg, "input required") || strings.Contains(msg, "paused for approval")
|
||||
}
|
||||
|
||||
func (d *dispatcher) taskFromReply(input Message, reply, state string) *Task {
|
||||
contextID := input.ContextID
|
||||
taskID := input.TaskID
|
||||
@@ -762,6 +893,68 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
|
||||
}
|
||||
}
|
||||
|
||||
func skillsFromServices(services []string) []Skill {
|
||||
if len(services) == 0 {
|
||||
return []Skill{{ID: "chat", Name: "Chat", Description: "Converse with the agent to operate its services."}}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var skills []Skill
|
||||
for _, service := range services {
|
||||
service = strings.TrimSpace(service)
|
||||
if service == "" {
|
||||
continue
|
||||
}
|
||||
id := skillID(service)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
skills = append(skills, Skill{
|
||||
ID: id,
|
||||
Name: skillName(service),
|
||||
Description: fmt.Sprintf("Operate the %s service through this agent.", service),
|
||||
Tags: []string{service},
|
||||
})
|
||||
}
|
||||
if len(skills) == 0 {
|
||||
return []Skill{{ID: "chat", Name: "Chat", Description: "Converse with the agent to operate its services."}}
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
func skillID(service string) string {
|
||||
service = strings.ToLower(strings.TrimSpace(service))
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range service {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
continue
|
||||
}
|
||||
if !dash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func skillName(service string) string {
|
||||
parts := strings.FieldsFunc(service, func(r rune) bool { return r == '-' || r == '_' || r == '.' || r == '/' || r == ' ' })
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func skillPrompt(skill Skill, text string) string {
|
||||
return fmt.Sprintf("Use the %q skill (%s) for this request.\n\n%s", skill.Name, skill.ID, text)
|
||||
}
|
||||
|
||||
func textOf(parts []Part) string {
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
|
||||
+291
-5
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -50,7 +51,7 @@ func newGatewayWithAgent(t *testing.T) (*httptest.Server, func()) {
|
||||
server.Name("echo"),
|
||||
server.Address("127.0.0.1:0"),
|
||||
server.Registry(reg),
|
||||
server.Metadata(map[string]string{"type": "agent", "services": ""}),
|
||||
server.Metadata(map[string]string{"type": "agent", "services": "task,project"}),
|
||||
)
|
||||
if err := pb.RegisterAgentHandler(srv, echoAgent{}); err != nil {
|
||||
t.Fatalf("register agent handler: %v", err)
|
||||
@@ -87,8 +88,43 @@ func TestAgentCardFromRegistry(t *testing.T) {
|
||||
if card.URL != "http://gw/agents/echo" {
|
||||
t.Errorf("card url = %q", card.URL)
|
||||
}
|
||||
if card.ProtocolVersion == "" || len(card.Skills) == 0 {
|
||||
t.Errorf("card missing protocolVersion or skills: %+v", card)
|
||||
if card.ProtocolVersion == "" {
|
||||
t.Errorf("card missing protocolVersion: %+v", card)
|
||||
}
|
||||
if !card.Capabilities.TaskResubscribe || !card.Capabilities.InputRequired {
|
||||
t.Errorf("card capabilities = %+v, want task resubscribe and input-required advertised", card.Capabilities)
|
||||
}
|
||||
if got := skillIDs(card.Skills); strings.Join(got, ",") != "task,project" {
|
||||
t.Errorf("skill IDs = %v, want [task project]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillEndpointServesFocusedCardAndRoutesRPC(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/agents/echo/skills/task/.well-known/agent.json")
|
||||
if err != nil {
|
||||
t.Fatalf("get skill card: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skill card status = %d", resp.StatusCode)
|
||||
}
|
||||
var card AgentCard
|
||||
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
|
||||
t.Fatalf("decode skill card: %v", err)
|
||||
}
|
||||
if card.URL != "http://gw/agents/echo/skills/task" || len(card.Skills) != 1 || card.Skills[0].ID != "task" {
|
||||
t.Fatalf("skill card = %+v, want task-only card at skill URL", card)
|
||||
}
|
||||
|
||||
task := rpcTask(t, ts.URL+"/agents/echo/skills/task", `{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"ping"}]}}}`)
|
||||
if task.Status.State != stateCompleted || textOf(task.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("skill task = %+v, want completed pong", task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +395,248 @@ func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type contextStream struct {
|
||||
ctx context.Context
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (s *contextStream) Recv() (*ai.Response, error) {
|
||||
<-s.ctx.Done()
|
||||
return nil, s.ctx.Err()
|
||||
}
|
||||
|
||||
func (s *contextStream) Close() error {
|
||||
close(s.closed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMessageStreamChunksPropagatesCancellationAndClosesStream(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
closed := make(chan struct{})
|
||||
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)).WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
cancel()
|
||||
|
||||
d.serveWithStream(rr, req, nil, func(ctx context.Context, text string) (ai.Stream, error) {
|
||||
if text != "ping" {
|
||||
t.Fatalf("stream text = %q, want ping", text)
|
||||
}
|
||||
return &contextStream{ctx: ctx, closed: closed}, nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stream was not closed")
|
||||
}
|
||||
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
event := events[0]
|
||||
if event.Error == nil || event.Error.Code != errInternal || event.Error.Message != context.Canceled.Error() {
|
||||
t.Fatalf("error = %+v, want context cancellation", event.Error)
|
||||
}
|
||||
if event.Result.Status.State != stateFailed || textOf(event.Result.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("failed task = %+v, want context cancellation artifact", event.Result)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, event.Result.ID)
|
||||
if got.Status.State != stateFailed || textOf(got.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("stored task = %+v, want failed cancellation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStreamChunksFallsBackWhenUnsupported(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()
|
||||
var streamed bool
|
||||
var fallbackText string
|
||||
|
||||
d.serveWithStream(rr, req, func(ctx context.Context, text string) (string, error) {
|
||||
fallbackText = text
|
||||
return "pong", nil
|
||||
}, func(ctx context.Context, text string) (ai.Stream, error) {
|
||||
streamed = true
|
||||
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
|
||||
})
|
||||
|
||||
if !streamed {
|
||||
t.Fatal("stream invoke was not attempted")
|
||||
}
|
||||
if fallbackText != "ping" {
|
||||
t.Fatalf("fallback text = %q, want ping", fallbackText)
|
||||
}
|
||||
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
if events[0].Error != nil {
|
||||
t.Fatalf("fallback event error: %+v", events[0].Error)
|
||||
}
|
||||
if events[0].Result.Status.State != stateCompleted || textOf(events[0].Result.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("fallback task = %+v, want completed pong", events[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
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)}}
|
||||
d.store(initial)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"tasks/resubscribe","params":{"id":"task-1"}}`)).WithContext(ctx)
|
||||
rw := newFlushRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.serve(rw, req, nil)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
first := rw.next(t)
|
||||
if first.Result.ID != initial.ID || first.Result.Status.State != stateWorking {
|
||||
t.Fatalf("first resubscribe event = %+v, want current working task", first.Result)
|
||||
}
|
||||
|
||||
final := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)}, Artifacts: []Artifact{textArtifact("done")}}
|
||||
d.store(final)
|
||||
second := rw.next(t)
|
||||
if second.Result.ID != final.ID || second.Result.Status.State != stateCompleted || textOf(second.Result.Artifacts[0].Parts) != "done" {
|
||||
t.Fatalf("second resubscribe event = %+v, want completed update", second.Result)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("resubscribe did not return after terminal update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRequiredErrorCreatesContinuableTask(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
first := rpcTaskFromBody(t, d, `{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"start approval"}]}}}`, func(_ context.Context, text string) (string, error) {
|
||||
return "", errors.New("agent run run-1 paused for approval: waiting for operator")
|
||||
})
|
||||
if first.Status.State != stateInputRequired {
|
||||
t.Fatalf("state = %q, want input-required", first.Status.State)
|
||||
}
|
||||
if textOf(first.Artifacts[0].Parts) != "agent run run-1 paused for approval: waiting for operator" {
|
||||
t.Fatalf("artifact = %+v, want handoff message", first.Artifacts)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`{
|
||||
"jsonrpc":"2.0","id":2,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m2","taskId":"%s","contextId":"%s",
|
||||
"parts":[{"kind":"text","text":"approved"}]}}}`, first.ID, first.ContextID)
|
||||
continued := rpcTaskFromBody(t, d, body, func(_ context.Context, text string) (string, error) {
|
||||
return "continued after " + text, nil
|
||||
})
|
||||
if continued.ID != first.ID || continued.ContextID != first.ContextID {
|
||||
t.Fatalf("continued identity = %s/%s, want %s/%s", continued.ID, continued.ContextID, first.ID, first.ContextID)
|
||||
}
|
||||
if continued.Status.State != stateCompleted || len(continued.History) != 4 {
|
||||
t.Fatalf("continued task = %+v, want completed task with prior input-required history", continued)
|
||||
}
|
||||
if textOf(continued.History[1].Parts) != "agent run run-1 paused for approval: waiting for operator" || textOf(continued.History[3].Parts) != "continued after approved" {
|
||||
t.Fatalf("continued history = %+v", continued.History)
|
||||
}
|
||||
}
|
||||
|
||||
type flushRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
ch chan string
|
||||
}
|
||||
|
||||
func newFlushRecorder() *flushRecorder {
|
||||
return &flushRecorder{ResponseRecorder: httptest.NewRecorder(), ch: make(chan string, 16)}
|
||||
}
|
||||
|
||||
func (r *flushRecorder) Flush() {
|
||||
body := r.Body.String()
|
||||
r.Body.Reset()
|
||||
for _, line := range strings.Split(strings.TrimSpace(body), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
r.ch <- line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *flushRecorder) next(t *testing.T) struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
} {
|
||||
t.Helper()
|
||||
select {
|
||||
case line := <-r.ch:
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
if event.Error != nil {
|
||||
t.Fatalf("event error: %+v", event.Error)
|
||||
}
|
||||
return event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for SSE event")
|
||||
}
|
||||
return struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}{}
|
||||
}
|
||||
|
||||
func rpcTaskFromDispatcher(t *testing.T, d *dispatcher, id string) Task {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"%s"}}`, id)
|
||||
@@ -408,9 +686,9 @@ func TestUnknownMethod(t *testing.T) {
|
||||
var resp struct {
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"tasks/resubscribe","params":{}}`, &resp)
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"unknown","params":{}}`, &resp)
|
||||
if resp.Error == nil || resp.Error.Code != errMethodNotFound {
|
||||
t.Errorf("expected method-not-found for resubscribe, got %+v", resp.Error)
|
||||
t.Errorf("expected method-not-found, got %+v", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,3 +734,11 @@ func rpcTask(t *testing.T, url, body string) Task {
|
||||
}
|
||||
return resp.Result
|
||||
}
|
||||
|
||||
func skillIDs(skills []Skill) []string {
|
||||
ids := make([]string, 0, len(skills))
|
||||
for _, skill := range skills {
|
||||
ids = append(ids, skill.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
+74
-1
@@ -1,11 +1,13 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -135,6 +137,77 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// Resubscribe reconnects to a retained or active task stream and returns task
|
||||
// snapshots as the remote agent emits updates. The returned channel is closed
|
||||
// when the task reaches a terminal state or ctx is canceled.
|
||||
func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan Task, <-chan error) {
|
||||
tasks := make(chan Task, 8)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(tasks)
|
||||
defer close(errs)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": uuid.New().String(),
|
||||
"method": "tasks/resubscribe",
|
||||
"params": getParams{ID: taskID},
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errs <- fmt.Errorf("tasks/resubscribe: status %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
var out struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &out); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if out.Error != nil {
|
||||
errs <- fmt.Errorf("a2a tasks/resubscribe: %s (%d)", out.Error.Message, out.Error.Code)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errs <- ctx.Err()
|
||||
return
|
||||
case tasks <- out.Result:
|
||||
}
|
||||
if terminal(out.Result.Status.State) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
return tasks, errs
|
||||
}
|
||||
|
||||
// SetPushNotificationConfig asks the remote agent to POST updates for taskID to cfg.URL.
|
||||
func (c *Client) SetPushNotificationConfig(ctx context.Context, taskID string, cfg PushNotificationConfig) error {
|
||||
_, err := c.call(ctx, "tasks/pushNotificationConfig/set", pushConfigParams{
|
||||
@@ -193,7 +266,7 @@ func (c *Client) call(ctx context.Context, method string, params any) (json.RawM
|
||||
|
||||
func terminal(state string) bool {
|
||||
switch state {
|
||||
case "completed", "failed", "canceled", "rejected":
|
||||
case "completed", "failed", "canceled", "rejected", "input-required":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -3,8 +3,10 @@ package a2a
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -110,3 +112,57 @@ func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
|
||||
t.Fatal("timed out waiting for push update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientResubscribeStreamsRetainedAndLiveTask(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)}}
|
||||
d.store(initial)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
d.serve(w, r, func(context.Context, string) (string, error) { return "", nil })
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
tasks, errs := NewClient(ts.URL).Resubscribe(ctx, initial.ID)
|
||||
|
||||
first := <-tasks
|
||||
if first.ID != initial.ID || first.Status.State != stateWorking {
|
||||
t.Fatalf("first resubscribe task = %+v, want retained working task", first)
|
||||
}
|
||||
final := &Task{ID: initial.ID, ContextID: initial.ContextID, Kind: "task", Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)}, Artifacts: []Artifact{textArtifact("done")}}
|
||||
d.store(final)
|
||||
second := <-tasks
|
||||
if second.ID != final.ID || second.Status.State != stateCompleted || textOf(second.Artifacts[0].Parts) != "done" {
|
||||
t.Fatalf("second resubscribe task = %+v, want live completed task", second)
|
||||
}
|
||||
if _, ok := <-tasks; ok {
|
||||
t.Fatal("resubscribe task channel stayed open after terminal update")
|
||||
}
|
||||
select {
|
||||
case err := <-errs:
|
||||
if err != nil {
|
||||
t.Fatalf("resubscribe error = %v", err)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSendMessageReturnsInputRequiredTask(t *testing.T) {
|
||||
card := Card("solo", "http://localhost:4000", "", []string{"task"})
|
||||
h := NewAgentHandler(card, func(context.Context, string) (string, error) {
|
||||
return "", errors.New("input-required: provide approval code")
|
||||
})
|
||||
ts := httptest.NewServer(h)
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
task, err := NewClient(ts.URL).SendMessage(ctx, Message{Parts: []Part{{Kind: "text", Text: "approve?"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if task.Status.State != stateInputRequired || !strings.Contains(textOf(task.Artifacts[0].Parts), "provide approval code") {
|
||||
t.Fatalf("task = %+v, want input-required handoff", task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HandlerOption configures NewHandler.
|
||||
type HandlerOption func(*handlerOptions)
|
||||
|
||||
type handlerOptions struct {
|
||||
serverName, serverVersion, protocolVersion string
|
||||
}
|
||||
|
||||
// WithServerInfo sets the name/version advertised in the initialize response.
|
||||
func WithServerInfo(name, version string) HandlerOption {
|
||||
return func(o *handlerOptions) { o.serverName, o.serverVersion = name, version }
|
||||
}
|
||||
|
||||
// WithProtocolVersion sets the MCP protocol version advertised in initialize.
|
||||
func WithProtocolVersion(v string) HandlerOption {
|
||||
return func(o *handlerOptions) { o.protocolVersion = v }
|
||||
}
|
||||
|
||||
// NewHandler returns an http.Handler serving the MCP protocol over HTTP as
|
||||
// JSON-RPC 2.0 (initialize, ping, notifications/*, tools/list, tools/call),
|
||||
// backed by the resolver. Mount it on your own server (e.g. POST /mcp): the
|
||||
// gateway provides the protocol; you keep your routes, middleware and any
|
||||
// human-facing docs page.
|
||||
func NewHandler(r Resolver, opts ...HandlerOption) http.Handler {
|
||||
o := handlerOptions{serverName: "go-micro-mcp", serverVersion: "1.0.0", protocolVersion: "2024-11-05"}
|
||||
for _, fn := range opts {
|
||||
fn(&o)
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var rpc struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&rpc); err != nil {
|
||||
writeRPCError(w, nil, ParseError, "Parse error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Notifications (and any id-less request) expect no response body.
|
||||
if strings.HasPrefix(rpc.Method, "notifications/") || len(rpc.ID) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := req.Context()
|
||||
switch rpc.Method {
|
||||
case "initialize":
|
||||
writeRPCResult(w, rpc.ID, map[string]interface{}{
|
||||
"protocolVersion": o.protocolVersion,
|
||||
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
||||
"serverInfo": map[string]interface{}{"name": o.serverName, "version": o.serverVersion},
|
||||
})
|
||||
case "ping":
|
||||
writeRPCResult(w, rpc.ID, map[string]interface{}{})
|
||||
case "tools/list":
|
||||
tools, err := r.List(ctx)
|
||||
if err != nil {
|
||||
writeRPCError(w, rpc.ID, InternalError, "Failed to list tools", err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
list = append(list, map[string]interface{}{
|
||||
"name": t.Name, "description": t.Description, "inputSchema": t.InputSchema,
|
||||
})
|
||||
}
|
||||
writeRPCResult(w, rpc.ID, map[string]interface{}{"tools": list})
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(rpc.Params, &p); err != nil {
|
||||
writeRPCError(w, rpc.ID, InvalidParams, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := r.Call(ctx, p.Name, p.Arguments)
|
||||
if err != nil {
|
||||
// Protocol/pre-check failure -> JSON-RPC error. An *RPCError
|
||||
// carries a specific code; anything else is InternalError.
|
||||
if rpcErr, ok := err.(*RPCError); ok {
|
||||
writeRPCError(w, rpc.ID, rpcErr.Code, rpcErr.Message, rpcErr.Data)
|
||||
} else {
|
||||
writeRPCError(w, rpc.ID, InternalError, "Tool call failed", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": res.Text}},
|
||||
}
|
||||
if res.IsError {
|
||||
result["isError"] = true
|
||||
}
|
||||
writeRPCResult(w, rpc.ID, result)
|
||||
default:
|
||||
writeRPCError(w, rpc.ID, MethodNotFound, "Method not found", rpc.Method)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": rawOrNull(id), "result": result})
|
||||
}
|
||||
|
||||
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": rawOrNull(id), "error": map[string]interface{}{"code": code, "message": msg, "data": data}})
|
||||
}
|
||||
|
||||
func rawOrNull(id json.RawMessage) interface{} {
|
||||
if len(id) == 0 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
// CallResult is the outcome of a successful tool dispatch. A tool that ran but
|
||||
// produced an error sets IsError — per the MCP spec this is returned as a
|
||||
// tools/call result with isError:true, not a JSON-RPC protocol error.
|
||||
type CallResult struct {
|
||||
Text string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// Error lets the package's RPCError (see stdio.go) be returned by a resolver
|
||||
// to signal a protocol/pre-check failure with a specific JSON-RPC code; the
|
||||
// handler maps it straight to the JSON-RPC error.
|
||||
func (e *RPCError) Error() string { return e.Message }
|
||||
|
||||
// ToolFunc executes a manually-registered tool. Return a *CallResult for tool
|
||||
// outcomes (set IsError for tool-level failures); return a non-nil error — an
|
||||
// *RPCError for a specific code — for protocol/pre-check failures.
|
||||
type ToolFunc func(ctx context.Context, args map[string]any) (*CallResult, error)
|
||||
|
||||
// Resolver supplies the gateway's tools and executes calls. Swapping the
|
||||
// resolver changes where tools come from without touching the MCP protocol or
|
||||
// transport:
|
||||
//
|
||||
// - NewManualResolver: tools you register explicitly (full product control,
|
||||
// including tools that are not go-micro services, executed via your own
|
||||
// logic — auth, metering, …).
|
||||
// - NewRegistryResolver: tools auto-discovered from registered services.
|
||||
//
|
||||
// The built-in store/broker tools are intentionally NOT exposed by any
|
||||
// resolver — they remain a development convenience on the legacy Serve() path.
|
||||
type Resolver interface {
|
||||
// List returns the current tool catalog.
|
||||
List(ctx context.Context) ([]Tool, error)
|
||||
// Call executes a tool by name with JSON arguments.
|
||||
Call(ctx context.Context, name string, args map[string]any) (*CallResult, error)
|
||||
}
|
||||
|
||||
// ManualResolver exposes an explicitly-registered set of tools.
|
||||
type ManualResolver struct {
|
||||
mu sync.RWMutex
|
||||
order []Tool
|
||||
funcs map[string]ToolFunc
|
||||
}
|
||||
|
||||
// NewManualResolver returns an empty manual resolver.
|
||||
func NewManualResolver() *ManualResolver {
|
||||
return &ManualResolver{funcs: map[string]ToolFunc{}}
|
||||
}
|
||||
|
||||
// Add registers (or replaces) a tool and its handler. Returns the resolver for
|
||||
// chaining.
|
||||
func (m *ManualResolver) Add(t Tool, fn ToolFunc) *ManualResolver {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.funcs[t.Name]; ok {
|
||||
for i := range m.order {
|
||||
if m.order[i].Name == t.Name {
|
||||
m.order[i] = t
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.order = append(m.order, t)
|
||||
}
|
||||
m.funcs[t.Name] = fn
|
||||
return m
|
||||
}
|
||||
|
||||
// List returns the registered tools.
|
||||
func (m *ManualResolver) List(_ context.Context) ([]Tool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]Tool, len(m.order))
|
||||
copy(out, m.order)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Call runs the handler registered for name.
|
||||
func (m *ManualResolver) Call(ctx context.Context, name string, args map[string]any) (*CallResult, error) {
|
||||
m.mu.RLock()
|
||||
fn, ok := m.funcs[name]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, &RPCError{Code: InvalidParams, Message: "Tool not found: " + name, Data: name}
|
||||
}
|
||||
return fn(ctx, args)
|
||||
}
|
||||
|
||||
// RegistryResolver auto-discovers tools from registered go-micro services and
|
||||
// executes them over RPC. It exposes only services — never the internal
|
||||
// store/broker tools.
|
||||
type RegistryResolver struct {
|
||||
tools *ai.Tools
|
||||
}
|
||||
|
||||
// NewRegistryResolver discovers services from reg and calls them with cl.
|
||||
func NewRegistryResolver(reg registry.Registry, cl client.Client) *RegistryResolver {
|
||||
return &RegistryResolver{tools: ai.NewTools(reg, ai.ToolClient(cl))}
|
||||
}
|
||||
|
||||
// List discovers the current service tools.
|
||||
func (r *RegistryResolver) List(_ context.Context) ([]Tool, error) {
|
||||
discovered, err := r.tools.Discover()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Tool, 0, len(discovered))
|
||||
for _, t := range discovered {
|
||||
out = append(out, Tool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": t.Properties},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Call executes a discovered service tool.
|
||||
func (r *RegistryResolver) Call(ctx context.Context, name string, args map[string]any) (*CallResult, error) {
|
||||
res := r.tools.Handler()(ctx, ai.ToolCall{ID: "1", Name: name, Input: args})
|
||||
return &CallResult{Text: res.Content}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManualResolverHandler(t *testing.T) {
|
||||
res := NewManualResolver().
|
||||
Add(Tool{Name: "echo", Description: "echoes text"},
|
||||
func(_ context.Context, args map[string]interface{}) (*CallResult, error) {
|
||||
s, _ := args["text"].(string)
|
||||
return &CallResult{Text: "you said: " + s}, nil
|
||||
}).
|
||||
Add(Tool{Name: "boom", Description: "errors"},
|
||||
func(_ context.Context, _ map[string]interface{}) (*CallResult, error) {
|
||||
return &CallResult{Text: "kaboom", IsError: true}, nil
|
||||
}).
|
||||
Add(Tool{Name: "blocked", Description: "coded error"},
|
||||
func(_ context.Context, _ map[string]interface{}) (*CallResult, error) {
|
||||
return nil, &RPCError{Code: -32000, Message: "insufficient credits"}
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(NewHandler(res))
|
||||
defer ts.Close()
|
||||
rpc := func(body string) (int, map[string]interface{}) {
|
||||
resp, err := http.Post(ts.URL, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post rpc: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
if _, out := rpc(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`); len(out["result"].(map[string]interface{})["tools"].([]interface{})) != 3 {
|
||||
t.Fatalf("tools/list: %v", out)
|
||||
}
|
||||
// tool result
|
||||
_, out := rpc(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}`)
|
||||
if out["result"].(map[string]interface{})["content"].([]interface{})[0].(map[string]interface{})["text"] != "you said: hi" {
|
||||
t.Fatalf("echo: %v", out)
|
||||
}
|
||||
// tool-level error -> isError result, NOT protocol error
|
||||
_, out = rpc(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"boom","arguments":{}}}`)
|
||||
if out["error"] != nil || out["result"].(map[string]interface{})["isError"] != true {
|
||||
t.Fatalf("boom should be isError result: %v", out)
|
||||
}
|
||||
// coded protocol error -> JSON-RPC error with the code
|
||||
_, out = rpc(`{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"blocked","arguments":{}}}`)
|
||||
if out["error"] == nil || int(out["error"].(map[string]interface{})["code"].(float64)) != -32000 {
|
||||
t.Fatalf("blocked should be -32000: %v", out)
|
||||
}
|
||||
// notification -> 204, no body
|
||||
code, _ := rpc(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("notification status = %d, want 204", code)
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,24 @@ consistent across providers.
|
||||
|
||||
The companion `TestAgentProviderConformanceFakeError` keeps provider error
|
||||
propagation covered locally without relying on external credentials.
|
||||
|
||||
## Local no-secret conformance
|
||||
|
||||
Use `make provider-conformance-mock` to run the same provider conformance harness
|
||||
through the deterministic mock provider. That target requires no API keys and is
|
||||
what `make harness` delegates to after the 0→1 and 0→hero scenarios, so every PR
|
||||
continues to exercise the provider-facing agent/tool contract without spending
|
||||
live model credits.
|
||||
|
||||
Use `make provider-conformance` when you want the live-provider sweep: providers
|
||||
without keys are skipped, and configured providers must satisfy the same harness
|
||||
contract.
|
||||
|
||||
## Scheduled CI
|
||||
|
||||
The daily/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
|
||||
checks stay deterministic and no-key environments remain green, but maintained
|
||||
provider credentials exercise the live matrix regularly.
|
||||
|
||||
@@ -122,6 +122,19 @@ database "agent", table "{name}":
|
||||
history — conversation history
|
||||
```
|
||||
|
||||
## Durable Ask / StreamAsk runs
|
||||
|
||||
Agents can opt into the same checkpoint backend used by flows with
|
||||
`micro.AgentWithCheckpoint(...)`. When enabled, each `Ask` or `StreamAsk` run is
|
||||
persisted with its input, terminal status, response, and tool-call records. If
|
||||
the process or transport drops after a tool has completed but before the model
|
||||
returns a final answer, restart the agent with the same checkpoint store and
|
||||
call `micro.AgentResume(ctx, ag, runID)` or
|
||||
`micro.AgentResumeStreamAsk(ctx, ag, runID)`.
|
||||
Completed tool calls are served from the checkpoint instead of being executed
|
||||
again, while guardrails such as `MaxSteps`, loop detection, approval pauses, and
|
||||
`request_input` pauses continue to apply to the resumed run.
|
||||
|
||||
## Built-in Capabilities
|
||||
|
||||
Beyond its scoped service tools, every agent gets two built-in tools. They are not service endpoints — they are capabilities the agent has over itself and over other agents. They are plain tools wired into the agent's tool handler; there is no separate harness, loop engine, or graph. The LLM calls them exactly like any other tool.
|
||||
|
||||
@@ -10,6 +10,26 @@ and can stop or revert anything at any time.
|
||||
> **services → agents → workflows**. Judge each change against it — work that
|
||||
> doesn't move toward that lifecycle isn't an improvement, however clean.
|
||||
|
||||
## The pipeline (planner → generator → evaluator)
|
||||
|
||||
The development process is an operational instance of the long-running-agent
|
||||
harness pattern ([Anthropic on harness design](https://www.anthropic.com/engineering/harness-design-long-running-apps)) —
|
||||
a planner, a generator, and a *separate* evaluator — distributed across GitHub
|
||||
Actions instead of subagents. Each role is a workflow:
|
||||
|
||||
| Role | Workflow (action name) | What it does |
|
||||
|------|------------------------|--------------|
|
||||
| **Planner** | `loop-architect.yml` — *Loop: Architect (Planner)* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`PRIORITIES.md`](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)* | On harness failure, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
|
||||
| **Coherence** | `loop-devrel.yml` — *Loop: DevRel* | Keeps README/website/docs/blog aligned with the North Star. |
|
||||
| **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. |
|
||||
|
||||
Generation is separated from evaluation on purpose: an agent grading its own work
|
||||
reliably over-rates it, so **CI and the harness — not the builder — are the gate**.
|
||||
The human sets direction and owns the calls that need taste (see Guardrails).
|
||||
|
||||
## Autonomy
|
||||
|
||||
Full autonomy, **no approval gates**. Each increment: Claude Code picks the work,
|
||||
@@ -72,7 +92,7 @@ Grounded in real signal, never speculative rewrites. Each cycle draws from:
|
||||
tracking issue for each increment and dispatches Codex there. It needs a
|
||||
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
without that secret the workflow deliberately no-ops to avoid ignored bot
|
||||
comments. See `.github/workflows/continuous-improvement.yml` and the mechanics
|
||||
comments. See `.github/workflows/loop-builder.yml` and the mechanics
|
||||
below.
|
||||
|
||||
## How the durable loop works (mechanics)
|
||||
@@ -127,13 +147,13 @@ The hourly loop ships increments; two periodic passes keep the *whole* heading i
|
||||
the right direction. Both use the same mechanism (fresh issue → `@codex` →
|
||||
output) but produce direction and coherence, not just code.
|
||||
|
||||
- **DevRel — daily** (`.github/workflows/devrel-review.yml`). Audits the public
|
||||
- **DevRel — daily** (`.github/workflows/loop-devrel.yml`). Audits the public
|
||||
surface (README, website landing + docs, blog) for coherence with the North
|
||||
Star, README crispness, and blog-worthy material. **Autonomy boundary:** safe
|
||||
factual-alignment and crispness fixes auto-merge like any increment;
|
||||
brand/positioning copy and blog drafts are *surfaced in a report* for the
|
||||
human, never auto-merged.
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/architecture-review.yml`).
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/loop-architect.yml`).
|
||||
The *founder lens*, running alongside the builders. Each run it **tracks live
|
||||
state** (what just merged, what's in flight), **prioritizes the roadmap**
|
||||
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
|
||||
@@ -154,6 +174,20 @@ roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in e
|
||||
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
|
||||
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
|
||||
|
||||
## Failure triage (the feedback loop)
|
||||
|
||||
The loop also closes on its own failures. `.github/workflows/loop-triage.yml`
|
||||
fires when the live provider-conformance harness finishes with `conclusion:
|
||||
failure` (scheduled/manual runs only), and dispatches Codex to **triage** the
|
||||
failing run: read the logs, root-cause each distinct failure, **dedupe** against
|
||||
open issues (comment "recurred" rather than filing a duplicate), and file a scoped
|
||||
`codex`/`enhancement` issue for each genuine, self-contained defect — which the
|
||||
increment loop then builds and the next harness run verifies. Transient flakes
|
||||
(live-model latency, provider outages) are ignored; anything needing a breaking or
|
||||
architectural change is escalated as `needs-human` instead of auto-built. This is
|
||||
the hill-climbing layer: CI/harness failures become fixes with no human in the
|
||||
middle, short of a decision that's genuinely the human's.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
- In-session: `CronDelete <id>` (or end the session).
|
||||
|
||||
@@ -19,14 +19,17 @@ redirect the loop; direction always wins.
|
||||
items the loop can auto-merge): brand/positioning copy, breaking public-API
|
||||
changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Now (ranked)
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Harden agent model-call resilience** ([#3233](https://github.com/micro/go-micro/issues/3233)) — complete the remaining hardening seam from the roadmap: deadline/cancellation propagation, bounded retry/backoff, and inspectable timeout/rate-limit/cancellation outcomes for the agent loop. This is the highest-value next item because durable runs, streaming, observability, memory, HITL, x402, and A2A continuation now exist; the shared operational harness still needs to fail safely under real provider conditions.
|
||||
2. **CI-verify the 0-to-1 getting-started path** ([#3234](https://github.com/micro/go-micro/issues/3234)) — add a deterministic no-key check for scaffold → run/build → call so the README/blog promise that building a service stays effortless cannot regress while the agent stack deepens.
|
||||
|
||||
## Later (ranked)
|
||||
|
||||
3. **Add A2A resubscribe and input-required handoff support** ([#3235](https://github.com/micro/go-micro/issues/3235)) — after push notifications and multi-turn continuation shipped, finish the remaining long-running A2A interoperability gap: reconnecting to live task streams and carrying human-input-required handoffs through the gateway.
|
||||
1. **Lead install docs with `@latest` now that the phantom-module retraction is published** ([#3565](https://github.com/micro/go-micro/issues/3565)) — install friction blocks every later experience. Make the first command current, boring, and consistent across README/website/docs so revived usage does not stall before scaffold/run.
|
||||
2. **Surface runnable examples from getting-started and the guides** ([#3562](https://github.com/micro/go-micro/issues/3562)) — the blog canon now has a concrete support-agent story, but developers need obvious links from docs into runnable code. Tie examples to the 0→1 and 0→hero paths so “read → run → modify one thing” is discoverable.
|
||||
3. **Reorder the guides nav to lead with doing before concepts** ([#3563](https://github.com/micro/go-micro/issues/3563)) — the framework is deep, but the first impression should be action: quickstart, first agent, examples, debugging, then architecture. This is small, CI-checkable wayfinding work with outsized adoption value.
|
||||
4. **Add a “Debugging your agent” guide focused on the dev workflow** ([#3564](https://github.com/micro/go-micro/issues/3564)) — the inner loop is scaffold → run → chat → inspect → deploy. Document how to see tool calls, run history, provider failures, guardrail refusals, and flow handoffs before adding more depth that users cannot diagnose.
|
||||
5. **Prevent duplicate tool side effects in the plan/delegate harness** ([#3559](https://github.com/micro/go-micro/issues/3559)) — correctness still matters where it protects real user trust. Plan/delegate is central to the services → agents lifecycle, and duplicate side effects undermine the “agent as dependable service” story.
|
||||
6. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — keep interop conformance trustworthy without letting it dominate the adoption queue. This is scoped, testable, and protects the A2A promise developers see in the README and site.
|
||||
7. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — after the on-ramp items, the highest-value remaining Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
8. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
|
||||
9. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// A2A stream fallback harness.
|
||||
//
|
||||
// It exercises the gateway boundary that fronts an agent over A2A. The agent is
|
||||
// configured with tools and memory, but its model streaming path deliberately
|
||||
// reports ai.ErrStreamingUnsupported; the A2A gateway must fall back to the
|
||||
// normal Ask path and still complete the same tool-calling run.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type mockModel struct{ opts ai.Options }
|
||||
|
||||
func newMock(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockModel) Options() ai.Options { return m.opts }
|
||||
func (m *mockModel) String() string { return "mock" }
|
||||
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if req.Prompt == "" {
|
||||
return nil, errors.New("missing prompt")
|
||||
}
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
|
||||
}
|
||||
if len(req.Tools) == 0 || m.opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tools or tool handler")
|
||||
}
|
||||
res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}})
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("empty tool result")
|
||||
}
|
||||
return &ai.Response{Reply: "fallback completed", Answer: res.Content, ToolCalls: []ai.ToolCall{{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}, Result: res.Content}}}, nil
|
||||
}
|
||||
|
||||
func providerKey(provider string) string {
|
||||
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
|
||||
return v
|
||||
}
|
||||
env := map[string]string{
|
||||
"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY", "groq": "GROQ_API_KEY", "mistral": "MISTRAL_API_KEY",
|
||||
"together": "TOGETHER_API_KEY", "atlascloud": "ATLASCLOUD_API_KEY",
|
||||
}[provider]
|
||||
return os.Getenv(env)
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
|
||||
apiKey := ""
|
||||
if *provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else {
|
||||
apiKey = providerKey(*provider)
|
||||
if apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mA2A streaming fallback conformance (provider: %s)\033[0m\n", *provider)
|
||||
reg := registry.NewMemoryRegistry()
|
||||
st := store.NewMemoryStore()
|
||||
var sawTool, sawRunInfo bool
|
||||
agentOpts := []agent.Option{
|
||||
agent.Name("a2a-fallback"),
|
||||
agent.Provider(*provider),
|
||||
agent.APIKey(apiKey),
|
||||
agent.Prompt("Use fallback_echo exactly once with value a2a-fallback, then answer with the tool result."),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithStore(st),
|
||||
agent.WithMemory(agent.NewInMemory(8)),
|
||||
agent.ModelCallTimeout(45 * time.Second),
|
||||
agent.WithTool("fallback_echo", "Echo the A2A fallback marker.", map[string]any{
|
||||
"value": map[string]any{"type": "string", "description": "value to echo"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok || info.RunID == "" || info.Agent != "a2a-fallback" {
|
||||
return "", fmt.Errorf("unexpected run info: %+v", info)
|
||||
}
|
||||
sawRunInfo = true
|
||||
if input["value"] != "a2a-fallback" {
|
||||
return "", fmt.Errorf("unexpected value %v", input["value"])
|
||||
}
|
||||
return `{"marker":"a2a-fallback-ok"}`, nil
|
||||
}),
|
||||
}
|
||||
agentOpts = append(agentOpts, harnessutil.AgentOptions(*provider)...)
|
||||
ag := agent.New(agentOpts...)
|
||||
|
||||
card := a2a.Card("a2a-fallback", "http://example.invalid/a2a-fallback", "", nil)
|
||||
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
resp, err := ag.Ask(ctx, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
}, ag.Stream)
|
||||
|
||||
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A fallback conformance check."}],"kind":"message"}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
res := rr.Result()
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b)
|
||||
os.Exit(1)
|
||||
}
|
||||
if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
|
||||
os.Exit(1)
|
||||
}
|
||||
payload, err := readSSEData(res.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !strings.Contains(payload, "a2a-fallback-ok") {
|
||||
fmt.Fprintf(os.Stderr, "stream payload missing marker: %s\n", payload)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !sawTool || !sawRunInfo {
|
||||
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ A2A message/stream fell back to Ask and preserved tool/run metadata\033[0m")
|
||||
}
|
||||
|
||||
func readSSEData(r io.Reader) (string, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var payload strings.Builder
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if data, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
payload.WriteString(data)
|
||||
payload.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.Len() == 0 {
|
||||
return "", errors.New("no SSE data received")
|
||||
}
|
||||
if !json.Valid([]byte(strings.TrimSpace(payload.String()))) {
|
||||
return "", fmt.Errorf("SSE data is not JSON: %s", payload.String())
|
||||
}
|
||||
return payload.String(), nil
|
||||
}
|
||||
@@ -25,10 +25,9 @@ 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/flow"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -203,8 +202,9 @@ func main() {
|
||||
fmt.Println("broker connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
cl := harnessutil.Client(*provider, reg)
|
||||
mem := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(*provider)
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
@@ -217,7 +217,7 @@ func main() {
|
||||
go nt.Run()
|
||||
|
||||
// The onboarder agent, registered so the flow can reach it over RPC.
|
||||
onboarder := agent.New(
|
||||
onboarderOpts := []agent.Option{
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
@@ -225,7 +225,9 @@ func main() {
|
||||
agent.Provider(*provider),
|
||||
agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
}
|
||||
onboarderOpts = append(onboarderOpts, liveAgentOpts...)
|
||||
onboarder := agent.New(onboarderOpts...)
|
||||
go onboarder.Run()
|
||||
defer onboarder.Stop()
|
||||
|
||||
@@ -238,6 +240,7 @@ func main() {
|
||||
flow.Trigger("events.user.created"),
|
||||
flow.Agent("onboarder"),
|
||||
flow.Prompt("A new user signed up: {{.Data}}. Get them set up."),
|
||||
flow.Timeout(harnessutil.LiveTimeout(*provider)),
|
||||
)
|
||||
if err := f.Register(reg, br, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package harnessutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
// LiveTimeoutEnv overrides the per-call deadline used by live-provider
|
||||
// harness runs. It intentionally does not affect deterministic mock runs.
|
||||
LiveTimeoutEnv = "GO_MICRO_HARNESS_LIVE_TIMEOUT"
|
||||
// DefaultLiveTimeout is generous enough for slow but correct hosted models
|
||||
// while still bounding genuinely stuck live conformance runs.
|
||||
DefaultLiveTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// LiveTimeout returns the harness per-call timeout for live providers. Mock runs
|
||||
// keep their historical fast defaults by returning zero.
|
||||
func LiveTimeout(provider string) time.Duration {
|
||||
if provider == "mock" {
|
||||
return 0
|
||||
}
|
||||
if raw := os.Getenv(LiveTimeoutEnv); raw != "" {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid %s=%q; using %s\n", LiveTimeoutEnv, raw, DefaultLiveTimeout)
|
||||
return DefaultLiveTimeout
|
||||
}
|
||||
return d
|
||||
}
|
||||
return DefaultLiveTimeout
|
||||
}
|
||||
|
||||
// Client returns an in-memory-registry client. Live provider harnesses get a
|
||||
// larger request timeout so an otherwise correct agent run is not cut off by the
|
||||
// default 30-second RPC deadline; mock runs are unchanged.
|
||||
func Client(provider string, reg registry.Registry) client.Client {
|
||||
opts := []client.Option{
|
||||
client.Registry(reg),
|
||||
client.Selector(selector.NewSelector(selector.Registry(reg))),
|
||||
}
|
||||
if d := LiveTimeout(provider); d > 0 {
|
||||
opts = append(opts, client.RequestTimeout(d))
|
||||
}
|
||||
return client.NewClient(opts...)
|
||||
}
|
||||
|
||||
// AgentOptions applies the same live-provider timeout to model and tool calls.
|
||||
// The empty result for mock runs preserves their deterministic timing.
|
||||
func AgentOptions(provider string) []agent.Option {
|
||||
if d := LiveTimeout(provider); d > 0 {
|
||||
return []agent.Option{agent.ModelCallTimeout(d), agent.ToolCallTimeout(d)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package harnessutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveTimeoutLeavesMockUnchanged(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "2m")
|
||||
if got := LiveTimeout("mock"); got != 0 {
|
||||
t.Fatalf("LiveTimeout(mock) = %s, want 0", got)
|
||||
}
|
||||
if opts := AgentOptions("mock"); len(opts) != 0 {
|
||||
t.Fatalf("AgentOptions(mock) length = %d, want 0", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTimeoutUsesDefaultForLiveProviders(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "")
|
||||
if got := LiveTimeout("atlascloud"); got != DefaultLiveTimeout {
|
||||
t.Fatalf("LiveTimeout(live) = %s, want %s", got, DefaultLiveTimeout)
|
||||
}
|
||||
if opts := AgentOptions("atlascloud"); len(opts) != 2 {
|
||||
t.Fatalf("AgentOptions(live) length = %d, want 2", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTimeoutCanBeOverridden(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "90s")
|
||||
if got := LiveTimeout("anthropic"); got != 90*time.Second {
|
||||
t.Fatalf("LiveTimeout override = %s, want 90s", got)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,9 @@ 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/flow"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -241,8 +240,9 @@ func runPlanDelegate(provider string) error {
|
||||
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
cl := harnessutil.Client(provider, reg)
|
||||
mem := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(provider)
|
||||
|
||||
// Real services.
|
||||
taskSvc := new(TaskService)
|
||||
@@ -260,26 +260,30 @@ func runPlanDelegate(provider string) error {
|
||||
go notify.Run()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
commsOpts := []agent.Option{
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
}
|
||||
commsOpts = append(commsOpts, liveAgentOpts...)
|
||||
comms := agent.New(commsOpts...)
|
||||
go comms.Run()
|
||||
defer comms.Stop()
|
||||
|
||||
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
|
||||
conductor := agent.New(
|
||||
conductorOpts := []agent.Option{
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
}
|
||||
conductorOpts = append(conductorOpts, liveAgentOpts...)
|
||||
conductor := agent.New(conductorOpts...)
|
||||
go conductor.Run()
|
||||
defer conductor.Stop()
|
||||
|
||||
@@ -303,6 +307,7 @@ 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.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Provider conformance
|
||||
|
||||
This harness keeps the services → agents → workflows lifecycle honest across the
|
||||
supported AI providers. It runs the same end-to-end scenarios against each
|
||||
configured provider and treats missing provider keys as an explicit skip, so the
|
||||
suite is safe for local development, forks, and scheduled CI.
|
||||
|
||||
## What it exercises
|
||||
|
||||
`go run ./internal/harness/provider-conformance` fans out over the provider-facing
|
||||
agent test and the harnesses in `internal/harness`:
|
||||
|
||||
- `agent` — provider tool-call conformance through `agent.Ask`, including run metadata propagation.
|
||||
- `universe` — service discovery plus agent tool calls over the real runtime.
|
||||
- `agent-flow` — a workflow event that drives an agent to call services.
|
||||
- `plan-delegate` — plan persistence plus agent-to-agent delegation and service
|
||||
calls.
|
||||
- `a2a-stream-fallback` — A2A `message/stream` through the gateway, including
|
||||
fallback from unsupported provider streaming to the tool-calling `Ask` path while
|
||||
preserving run metadata.
|
||||
|
||||
The command also emits the registered provider capability matrix so the run shows
|
||||
which providers advertise model, image, video, and streaming support. Console output
|
||||
and summary artifacts label each harness with the phase it is proving (for example,
|
||||
model call + tool call, workflow event + tool call, or streaming fallback + tool
|
||||
call), so provider failures identify the failed lifecycle phase instead of only the
|
||||
provider name.
|
||||
|
||||
## Local usage
|
||||
|
||||
Run the deterministic path with no secrets:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance -providers mock
|
||||
```
|
||||
|
||||
Run every live provider that has a key in the environment:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-summary-markdown provider-conformance-summary.md \
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
```
|
||||
|
||||
Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable:
|
||||
|
||||
| Provider | Secret / environment variable |
|
||||
| --- | --- |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI | `OPENAI_API_KEY` |
|
||||
| Gemini | `GEMINI_API_KEY` |
|
||||
| Groq | `GROQ_API_KEY` |
|
||||
| Mistral | `MISTRAL_API_KEY` |
|
||||
| Together | `TOGETHER_API_KEY` |
|
||||
| AtlasCloud | `ATLASCLOUD_API_KEY` |
|
||||
|
||||
Use `-require-configured` when you want a selected provider without a key to fail
|
||||
instead of skip. This is useful for manually checking that a required provider
|
||||
secret is actually wired into CI before relying on that provider as covered:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-providers anthropic,openai \
|
||||
-require-configured
|
||||
```
|
||||
|
||||
## 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
|
||||
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
|
||||
missing; scheduled runs keep the safe default and report missing keys as skips.
|
||||
That job:
|
||||
|
||||
1. runs the same `agent`, `universe`, `agent-flow`, `plan-delegate`, and `a2a-stream-fallback` harness list,
|
||||
2. reads the provider keys from repository secrets,
|
||||
3. skips providers whose secrets are absent,
|
||||
4. fails when any configured provider fails a harness, and
|
||||
5. uploads JSON and Markdown coverage artifacts for the run.
|
||||
|
||||
The job also appends the Markdown summary and capability matrix to the GitHub
|
||||
Actions step summary, making configured, skipped, and failed provider coverage
|
||||
visible without downloading artifacts.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
To bring a new provider into scheduled conformance:
|
||||
|
||||
1. register its `ai` provider implementation and capability metadata,
|
||||
2. add the provider name and key variable to `providerEnv` in `main.go`,
|
||||
3. import the provider package in `main.go`,
|
||||
4. pass the matching repository secret through `.github/workflows/harness.yml`,
|
||||
and
|
||||
5. run `go run ./internal/harness/provider-conformance -providers <name> \
|
||||
-require-configured` with a live key before opening the change.
|
||||
@@ -34,6 +34,16 @@ import (
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
const defaultHarnesses = "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
|
||||
|
||||
var harnessPhases = map[string]string{
|
||||
"agent": "model call + tool call",
|
||||
"universe": "service discovery + tool call",
|
||||
"agent-flow": "workflow event + tool call",
|
||||
"plan-delegate": "plan persistence + delegation + tool call",
|
||||
"a2a-stream-fallback": "streaming fallback + tool call",
|
||||
}
|
||||
|
||||
var providerEnv = map[string]string{
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
@@ -45,8 +55,8 @@ var providerEnv = map[string]string{
|
||||
}
|
||||
|
||||
func main() {
|
||||
providersFlag := flag.String("providers", "anthropic,openai,gemini,groq,mistral,together,atlascloud", "comma-separated providers to check; use mock for deterministic local checks")
|
||||
harnessesFlag := flag.String("harnesses", "universe,agent-flow,plan-delegate", "comma-separated harness names under internal/harness")
|
||||
providersFlag := flag.String("providers", defaultProviders(), "comma-separated providers to check; use mock for deterministic local checks")
|
||||
harnessesFlag := flag.String("harnesses", defaultHarnesses, "comma-separated harness names under internal/harness; agent runs the provider tool-call conformance test")
|
||||
timeoutFlag := flag.Duration("timeout", 10*time.Minute, "timeout per provider/harness run")
|
||||
requireConfiguredFlag := flag.Bool("require-configured", false, "fail when a selected live provider is missing an API key")
|
||||
capabilitiesFlag := flag.Bool("capabilities", true, "print the registered provider capability matrix before running conformance")
|
||||
@@ -90,15 +100,16 @@ func main() {
|
||||
}
|
||||
|
||||
for _, harness := range harnesses {
|
||||
fmt.Printf("\n==> %s / %s\n", provider, harness)
|
||||
phase := harnessPhase(harness)
|
||||
fmt.Printf("\n==> %s / %s (%s)\n", provider, harness, phase)
|
||||
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
|
||||
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
|
||||
fmt.Printf("FAIL %s / %s (%s): %v\n", provider, harness, phase, err)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusFailed, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusPassed})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +149,7 @@ const (
|
||||
type conformanceResult struct {
|
||||
Provider string `json:"provider"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -169,17 +181,23 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Provider conformance summary\n\n")
|
||||
fmt.Fprintf(&b, "Passed: %d. Skipped providers: %d. Failed: %d.\n\n", summary.Passed, summary.Skipped, summary.Failed)
|
||||
fmt.Fprintf(&b, "Providers: %s.\n\n", markdownList(summary.Providers))
|
||||
fmt.Fprintf(&b, "Harnesses: %s.\n\n", markdownList(summary.Harnesses))
|
||||
b.WriteString("## Capability matrix\n\n")
|
||||
b.WriteString(capabilityMarkdown(summary.Capabilities))
|
||||
b.WriteString("\n## Harness results\n\n")
|
||||
b.WriteString("| Provider | Harness | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- |\n")
|
||||
b.WriteString("| Provider | Harness | Phase | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
for _, result := range summary.Results {
|
||||
harness := result.Harness
|
||||
if harness == "" {
|
||||
harness = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
|
||||
phase := result.Phase
|
||||
if phase == "" {
|
||||
phase = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", result.Provider, harness, markdownCell(phase), result.Status, markdownCell(result.Error))
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
@@ -194,6 +212,24 @@ func capabilityMarkdown(rows []ai.CapabilityRow) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func markdownList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
escaped := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
escaped = append(escaped, "`"+strings.ReplaceAll(value, "`", "\\`")+"`")
|
||||
}
|
||||
return strings.Join(escaped, ", ")
|
||||
}
|
||||
|
||||
func harnessPhase(harness string) string {
|
||||
if phase, ok := harnessPhases[harness]; ok {
|
||||
return phase
|
||||
}
|
||||
return "harness"
|
||||
}
|
||||
|
||||
func markdownCell(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
@@ -244,6 +280,9 @@ func validateSelection(providers, harnesses []string) error {
|
||||
}
|
||||
|
||||
for _, harness := range harnesses {
|
||||
if harness == "agent" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(harness, string(os.PathSeparator)) || harness == "." || harness == ".." {
|
||||
return fmt.Errorf("invalid harness name %q", harness)
|
||||
}
|
||||
@@ -276,6 +315,15 @@ func repoRoot() string {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProviders() string {
|
||||
providers := make([]string, 0, len(providerEnv))
|
||||
for provider := range providerEnv {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
slices.Sort(providers)
|
||||
return strings.Join(providers, ",")
|
||||
}
|
||||
|
||||
func knownProviders() string {
|
||||
providers := make([]string, 0, len(providerEnv)+1)
|
||||
providers = append(providers, "mock")
|
||||
@@ -323,6 +371,10 @@ func localRPCEnv(env []string) []string {
|
||||
}
|
||||
|
||||
func runHarness(provider, harness string, timeout time.Duration) error {
|
||||
if harness == "agent" {
|
||||
return runAgentConformance(provider, timeout)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -358,3 +410,28 @@ func runHarness(provider, harness string, timeout time.Duration) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentConformance(provider string, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
testProvider := provider
|
||||
if provider == "mock" {
|
||||
testProvider = "fake"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProviderConformanceMatrix", "-count=1", "-v")
|
||||
cmd.Dir = repoRoot()
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = localRPCEnv(append(os.Environ(),
|
||||
"GO_MICRO_AGENT_CONFORMANCE_LIVE=1",
|
||||
"GO_MICRO_AGENT_CONFORMANCE_PROVIDERS="+testProvider,
|
||||
))
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user