Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 9705892233 Update architecture priorities
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-06-28 21:31:03 +00:00
160 changed files with 487 additions and 9788 deletions
+1 -1
View File
@@ -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/G8Gk5j3uXr)
- [Discord Community](https://discord.gg/WeMU5AGxD)
+1 -1
View File
@@ -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/G8Gk5j3uXr)
- [Discord Community](https://discord.gg/WeMU5AGxD)
@@ -1,4 +1,4 @@
name: "Loop: Architect (Planner)"
name: Architecture Review
# 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? 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)."
"@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)."
@@ -1,4 +1,4 @@
name: "Loop: Builder (Generator)"
name: Continuous Improvement
# Durable backbone for the autonomous improvement loop
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
+47
View File
@@ -0,0 +1,47 @@
name: DevRel Review
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
# code; this keeps the story coherent: docs/website aligned, README crisp, and
# a steady supply of things worth blogging about.
#
# Like the increment loop it opens a fresh issue and dispatches Codex via
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
# SAFE factual-alignment and crispness fixes auto-merge; brand/positioning copy
# and blog drafts are surfaced in the report for the human, never auto-merged.
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
permissions:
issues: write
concurrency:
group: devrel-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a DevRel review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "DevRel coherence review #$RUN_NUMBER" \
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog. North Star: internal/docs/THESIS.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as DevRel for go-micro. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs / CHANGELOG.md); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work. Then do BOTH of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, and the blog ideas; (B) for SAFE factual-alignment and crispness fixes only (NOT brand/marketing/positioning rewrites), open one PR: \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. Leave brand/positioning copy and blog drafts for the human — describe them in the report, do NOT open auto-merging PRs for them. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
+23 -51
View File
@@ -12,22 +12,8 @@ on:
pull_request:
branches: ["**"]
schedule:
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
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:
@@ -41,8 +27,14 @@ jobs:
cache: true
- name: Build
run: go build ./...
- name: 0→1 and 0→hero developer-flow harness
run: make harness
- 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 run/chat/inspect reference scenario
run: ./internal/harness/zero-to-hero-ci/run.sh
harness-live:
name: Provider harnesses (live LLM conformance)
@@ -58,6 +50,17 @@ jobs:
with:
go-version: stable
cache: true
- name: Agent provider conformance matrix
env:
GO_MICRO_AGENT_CONFORMANCE_LIVE: "1"
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
run: go test ./agent -run TestAgentProviderConformanceMatrix -count=1 -v
- name: Provider conformance against live models
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -67,42 +70,11 @@ 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: |
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
go run ./internal/harness/provider-conformance \
-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
-61
View File
@@ -1,61 +0,0 @@
name: "Loop: DevRel"
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
# code; this keeps the story coherent: docs/website aligned, README crisp, and
# a steady supply of things worth blogging about.
#
# It also keeps the CHANGELOG living: each run reconciles the `[Unreleased]`
# section of CHANGELOG.md against what actually merged (rolling it into a dated
# version heading whenever a new tag was cut), and — when enough has shipped —
# drafts a "what's new" changelog blog post narrating it.
#
# Like the increment loop it opens a fresh issue and dispatches Codex via
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
# SAFE factual-alignment and crispness fixes — including CHANGELOG.md upkeep —
# auto-merge; brand/positioning copy and the changelog blog post are opened as a
# PR but left for the human to review/merge (blog voice stays with the human).
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
permissions:
issues: write
concurrency:
group: devrel-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a DevRel review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "DevRel coherence review #$RUN_NUMBER" \
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog, plus CHANGELOG.md upkeep and a changelog blog post. North Star: internal/docs/THESIS.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as DevRel for go-micro. Do FOUR things this run.
COHERENCE AUDIT. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (this is a SAFE factual task — it goes in the auto-merged PR). Keep CHANGELOG.md living, in [Keep a Changelog](https://keepachangelog.com/) format with the newest content at the top under \`## [Unreleased]\`. (a) Enumerate PRs merged to master since the last CHANGELOG update — \`gh pr list --repo $REPO --state merged --base master --limit 60 --json number,title,mergedAt,labels\` — and compare against what CHANGELOG.md already lists. (b) For each genuinely user-facing change not yet recorded (new capability, behavior/API change, notable fix — SKIP purely internal loop/CI/priorities-refresh churn), add a concise entry under the right \`### Added\` / \`### Changed\` / \`### Fixed\` / \`### Documentation\` subheading of \`## [Unreleased]\`, phrased for a user (what it does, which package), not a commit subject. (c) If a new \`v6.MINOR.PATCH\` tag has been cut since the last run (\`git fetch --tags --force\`; compare the newest \`v6.*\` tag to the versions already in CHANGELOG.md), RENAME the current \`## [Unreleased]\` heading to \`## [MINOR.PATCH] - <Month YYYY>\` for that tag and open a fresh empty \`## [Unreleased]\` above it. Keep it accurate — do not invent entries; if nothing user-facing merged, leave [Unreleased] as-is.
CHANGELOG BLOG POST (blog voice — open a PR but do NOT auto-merge; leave it for the human). If — and only if — enough user-facing work has accumulated since the last changelog post to be worth reading (a meaningful batch, roughly a week's worth; do NOT post an almost-empty update every day), draft a short 'What's new in Go Micro' post that narrates what shipped in plain language (grouped by theme, linking the docs/examples, closing with install/upgrade). Create it as the next-numbered file in \`internal/website/blog/\` (find the highest N, use N+1), mirroring the frontmatter (layout/title/permalink/description) and the post-nav 'previous post' link of the latest existing post, and add an entry at the TOP of \`internal/website/blog/index.html\`. Base it strictly on the CHANGELOG — no speculation.
THEN do all of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, the CHANGELOG entries you added, whether you drafted a changelog post (and why / why not), and any other blog ideas. (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG.md update (NOT brand/marketing/positioning rewrites, NOT the blog post): \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR on its own branch (\`codex/devrel-blog-$ISSUE_NUM\`) with a title prefixed 'blog:' and do NOT enable auto-merge — leave it open for the human to review and merge. Do the same (separate, non-auto-merged PR or just a report note) for any brand/positioning copy. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
-84
View File
@@ -1,84 +0,0 @@
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
# Do NOT persist the default GITHUB_TOKEN as a git credential.
# actions/checkout otherwise sets an http.extraheader Authorization
# for github.com that is sent on ALL pushes — including our manual
# PAT push below — and overrides the PAT, so the tag push
# authenticates as github-actions[bot] and 403s (the job only has
# contents: read). With this off, the PAT embedded in the push URL
# is the only credential. (Fixes run 28554612450.)
persist-credentials: false
- 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."
-59
View File
@@ -1,59 +0,0 @@
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
View File
@@ -62,7 +62,6 @@ examples/mcp/hello/hello
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
+2 -35
View File
@@ -2,41 +2,8 @@
All notable changes to Go Micro are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
follow [Semantic Versioning](https://semver.org/), matching the git tags and
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
Patch releases are cut automatically as the loop merges improvements; the
`[Unreleased]` section below is kept current between tags and rolled into the
next version when it ships.
> Earlier `2026.0x` headings are historical calendar-style markers from before
> v6 tagging; they are kept for continuity and not reused.
---
## [Unreleased]
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows for a planner (keeps a ranked queue), builder (builds the top item as a single-concern PR, auto-merged on green CI), and triage (turns CI failures into fix issues), dispatched to an @mention-driven coding agent. `micro loop init` writes the workflows + `NORTH_STAR`/`PRIORITIES`; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
---
## [6.3.12] - July 2026
### Added
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
- **A2A streaming & continuity** — outbound agent streaming flows through the A2A binding (`message/stream`), with `tasks/resubscribe` and `input-required` handoffs for multi-turn interop. (`gateway/a2a/`)
### Changed
- **Agent tool-call resilience** — opt-in retries around agent tool calls, and a fallback that executes tool calls emitted as text by weaker models so they still make progress. (`agent/`)
- **Hardened agent durability** — terminal failure statuses are classified and surfaced, and durable resume-after-restart is covered by tests. (`agent/`)
### Documentation
- **"Your first agent" walkthrough** and a canonical 0-to-hero reference path, lowering the on-ramp from install to a running agent. (`internal/website/docs/`)
- **Discord** linked prominently across the README, website nav/footer, and docs. (`https://discord.gg/G8Gk5j3uXr`)
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
calendar-based versions (YYYY.MM) for the AI-native era.
---
-11
View File
@@ -6,17 +6,6 @@ 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
+6 -23
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -18,9 +18,7 @@ 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 getting-started and end-to-end harnesses"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make harness - Run deterministic end-to-end harnesses"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@echo " make install-tools - Install development tools"
@@ -44,27 +42,12 @@ test-coverage:
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# 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.
# Run the end-to-end harnesses (deterministic, mock LLM — no API key).
# The universe harness exits non-zero on assertion failure.
harness:
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/universe
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
./internal/harness/install-smoke/run.sh
# 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
go run ./internal/harness/plan-delegate # 0→hero: services + agents + flow + plan/delegate
# Run the same harnesses against every configured live provider. Providers
# without API keys are skipped; configured providers must pass.
+6 -41
View File
@@ -1,9 +1,7 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro)
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.
@@ -16,7 +14,7 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
&nbsp;&nbsp;
<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/G8Gk5j3uXr) — reach out on Discord.
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
## Commercial Support
@@ -25,7 +23,6 @@ Running Go Micro in production, or building on it and want help? Paid **support,
## Contents
- [Quick Start](#quick-start)
- [First agent on-ramp](#first-agent-on-ramp)
- [Why an Agent Harness](#why-an-agent-harness)
- [Writing Services](#writing-services)
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
@@ -47,7 +44,7 @@ Install the CLI:
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@latest
go install go-micro.dev/v6/cmd/micro@v6
```
### Fastest start — no API key
@@ -67,35 +64,6 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
verify just the local installer and first-run CLI boundaries without network
access or provider keys, use:
```bash
make install-smoke
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### First agent on-ramp
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
2. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro agent inspect`, run history, memory, and provider checks when the first
conversation does something unexpected.
3. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
### 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:
@@ -330,7 +298,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
| MCP gateway | Every endpoint is an AI tool automatically |
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -425,9 +393,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 | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| Ollama | `llama3.2` (local) |
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `llama-3.3-70b` |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
@@ -448,8 +415,6 @@ 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
View File
@@ -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/G8Gk5j3uXr). Include tests, run `make test` and
[Discord](https://discord.gg/WeMU5AGxD). Include tests, run `make test` and
`make lint`.
## Version support
+1 -1
View File
@@ -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/G8Gk5j3uXr
- Join Discord: https://discord.gg/WeMU5AGxD
- Email: support@go-micro.dev
-102
View File
@@ -1,102 +0,0 @@
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)
}
}
+10 -54
View File
@@ -19,7 +19,6 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
@@ -34,7 +33,6 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
@@ -141,38 +139,19 @@ 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))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
// 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.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
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.
@@ -182,9 +161,7 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
@@ -248,18 +225,16 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
if addUserMessage {
a.mem.Add("user", message)
}
a.mem.Add("user", message)
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
@@ -278,9 +253,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
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()
@@ -304,15 +276,12 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
run.Status = "failed"
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
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
}
@@ -333,19 +302,6 @@ 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)
}
@@ -445,7 +401,7 @@ func (a *agentImpl) Run() error {
return "", err
}
return resp.Reply, nil
}, a.streamAskAI)
}, a.Stream)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
+2 -151
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
@@ -114,15 +113,13 @@ 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.toolTimeoutWrap(a.tools.Handler())
return 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 → 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)
@@ -151,114 +148,6 @@ 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 {
@@ -290,11 +179,7 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
if call.Name == toolPlan {
return a.handlePlan(call)
}
res := next(ctx, call)
if res.Refused == "" && toolErrorMessage(res) == "" {
a.completeNextPlanStep()
}
return res
return next(ctx, call)
}
}
@@ -372,36 +257,6 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
}
func (a *agentImpl) completeNextPlanStep() {
plan := a.loadPlan()
if plan == "" {
return
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return
}
steps, ok := data["steps"].([]any)
if !ok {
return
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status == "" || status == "pending" || status == "in_progress" {
step["status"] = "done"
b, err := json.Marshal(data)
if err == nil {
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
}
return
}
}
}
// 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)
@@ -464,10 +319,6 @@ 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.
+3 -34
View File
@@ -49,12 +49,6 @@ 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
}
@@ -94,9 +88,6 @@ 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()
@@ -104,7 +95,7 @@ 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, false)
return a.askLocked(ctx, run.ID, message, parentID, &run)
}
// ResumeInput resumes a checkpointed agent run that paused via the built-in
@@ -149,7 +140,7 @@ func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Resp
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
return a.askLocked(ctx, run.ID, message, run.ParentID, &run)
}
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
@@ -162,35 +153,13 @@ func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
}
out := runs[:0]
for _, run := range runs {
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
if run.Flow == a.opts.Name && run.Status != "done" {
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 {
+5 -253
View File
@@ -13,7 +13,7 @@ import (
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "durable-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
@@ -52,7 +52,7 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-resume-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "tool-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
@@ -102,183 +102,9 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
toolRuns := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "pending"},
},
}})
for i := 0; i < 3; i++ {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
if res.Content != "created Design" {
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
}
}
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created Design", nil
}))
if _, err := a.Ask(ctx, "create Design once"); err != nil {
t.Fatalf("Ask: %v", err)
}
if toolRuns != 1 {
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
}
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
t.Fatalf("plan = %s, want completed action marked done", plan)
}
}
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.NewMemoryStore(), "pending-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "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)
@@ -293,38 +119,9 @@ 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")
cp := flow.StoreCheckpoint(store.NewStore(), "input-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
@@ -380,54 +177,9 @@ func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
}
}
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.NewMemoryStore(), "approval-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
-62
View File
@@ -33,29 +33,14 @@ 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 {
@@ -171,50 +156,3 @@ 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)
}
}
+9 -55
View File
@@ -24,12 +24,6 @@ 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
@@ -37,7 +31,6 @@ type MemorySummaryFunc func([]ai.Message) ai.Message
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
Summarize MemorySummaryFunc
}
// MemoryRecall is implemented by memory backends that can retrieve durable
@@ -56,29 +49,11 @@ 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
}
@@ -94,7 +69,6 @@ func NewCompactingMemoryWithOptions(s store.Store, key string, compaction Memory
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
Summarize: compaction.Summarize,
},
}
m.load()
@@ -110,20 +84,16 @@ 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
retrieveAll bool
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
}
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()
@@ -147,8 +117,6 @@ 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()
@@ -202,9 +170,6 @@ 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)
}
@@ -248,13 +213,9 @@ func (m *storeMemory) compact() {
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summarize := m.compaction.Summarize
if summarize == nil {
summarize = defaultMemorySummary
}
summary := summarize(older)
if summary.Role == "" {
summary.Role = "system"
summary := ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
@@ -263,13 +224,6 @@ 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 {
-75
View File
@@ -3,11 +3,9 @@ package agent
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
@@ -64,49 +62,6 @@ 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)
@@ -147,36 +102,6 @@ func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
}
}
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
+1 -76
View File
@@ -40,7 +40,6 @@ type Options struct {
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
@@ -57,23 +56,10 @@ 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.
@@ -105,11 +91,6 @@ 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
@@ -126,9 +107,6 @@ 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,
@@ -169,12 +147,6 @@ func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// BaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// Address sets the network address for the agent's service endpoint.
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
// port and avoid advertising the default service address.
@@ -227,14 +199,6 @@ 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) {
@@ -243,16 +207,6 @@ 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;
@@ -268,40 +222,19 @@ 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.MaxMessages = maxMessages
o.MemoryCompaction.KeepRecent = keepRecent
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, 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 {
@@ -362,11 +295,3 @@ 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 }
}
+65 -174
View File
@@ -22,51 +22,36 @@ 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"
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"
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"
)
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"`
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"`
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"`
}
type Usage = ai.Usage
@@ -75,8 +60,7 @@ 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", "canceled", "timeout",
// "rate_limited", "error", or "refused").
// (for example "running", "done", "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
@@ -89,19 +73,18 @@ 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"`
LastErrorKind string `json:"last_error_kind,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"`
}
func (a *agentImpl) tracer() trace.Tracer {
@@ -111,38 +94,29 @@ 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)
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
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(), ErrorKind: string(ai.ClassifyError(err))})
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
return
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
}
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)
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})
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(), ErrorKind: string(ai.ClassifyError(err))})
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
} 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})
@@ -170,32 +144,24 @@ 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, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: 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}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordRunEvent(e)
return resp, err
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
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)}
if info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
}
if info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, info.MaxAttempts))
}
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
@@ -203,17 +169,15 @@ 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, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: 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}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordSpanEvent(span, e)
return resp, err
@@ -240,8 +204,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
if a.opts.TraceProvider == nil {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
resErr := resultError(res)
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
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)})
return res
}
@@ -258,11 +221,8 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
resErr := resultError(res)
if kind := classifyToolError(resErr); kind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, kind))
}
span.SetAttributes(attrs...)
resErr := resultError(res)
if res.Refused != "" {
span.SetStatus(codes.Error, res.Refused)
} else if resErr != "" {
@@ -271,7 +231,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, ErrorKind: classifyToolError(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})
return res
}
}
@@ -288,28 +248,6 @@ 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()
@@ -336,18 +274,9 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
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))
@@ -355,33 +284,6 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
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
}
@@ -461,9 +363,6 @@ 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
@@ -490,32 +389,24 @@ func runStatus(events []RunEvent) string {
}
status := "running"
for _, e := range events {
if e.Refused != "" && status == "running" {
if e.Error != "" {
status = "error"
}
if e.Refused != "" && status != "error" {
status = "refused"
}
if e.Error != "" || e.Kind == "error" {
status = runErrorStatus(e.ErrorKind)
}
if e.Kind == "done" && status == "running" {
status = "done"
switch e.Kind {
case "error":
status = "error"
case "done":
if 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 + "/"))
+5 -245
View File
@@ -3,23 +3,18 @@ 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 {
@@ -94,9 +89,6 @@ 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 {
@@ -133,146 +125,6 @@ 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 {
@@ -389,74 +241,6 @@ 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")
@@ -498,7 +282,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: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -521,35 +305,11 @@ 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 != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
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" {
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")
@@ -557,7 +317,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: "rate limit exceeded", ErrorKind: string(ai.ErrorKindRateLimited)},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -569,11 +329,11 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
}
}
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "rate_limited", TraceID: "abcdef", Limit: 1})
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "error", TraceID: "abcdef", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "rate_limited" {
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "error" {
t.Fatalf("filtered summaries = %#v", got)
}
}
-127
View File
@@ -8,8 +8,6 @@ import (
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
@@ -104,128 +102,3 @@ 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
View File
@@ -1,277 +0,0 @@
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
}
-175
View File
@@ -1,175 +0,0 @@
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" }
-141
View File
@@ -1,141 +0,0 @@
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
}
+3 -9
View File
@@ -77,7 +77,7 @@ 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": anthropicMaxTokens(p.opts),
"max_tokens": 8192,
"system": req.SystemPrompt,
"messages": threadAnthropicMessages(req),
}
@@ -124,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": anthropicMaxTokens(p.opts),
"max_tokens": 8192,
"system": req.SystemPrompt,
"messages": messages,
}
@@ -268,6 +268,7 @@ 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.
@@ -281,10 +282,3 @@ func threadAnthropicMessages(req *ai.Request) []map[string]any {
}
return msgs
}
func anthropicMaxTokens(o ai.Options) int {
if o.MaxTokens > 0 {
return o.MaxTokens
}
return 8192
}
+2 -118
View File
@@ -20,14 +20,12 @@
package atlascloud
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -44,7 +42,6 @@ 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.
@@ -57,14 +54,7 @@ func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
// 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"
}
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.atlascloud.ai"
@@ -113,9 +103,6 @@ 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(tools) > 0 {
apiReq["tools"] = tools
@@ -160,111 +147,8 @@ 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) {
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()
return nil, fmt.Errorf("%w: atlascloud provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+7 -53
View File
@@ -2,11 +2,7 @@ package atlascloud
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -85,58 +81,16 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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()
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
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")
req := &ai.Request{
Prompt: "Hello",
}
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)
_, err := p.Stream(context.Background(), req)
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+7 -7
View File
@@ -34,7 +34,7 @@ func TestRegisteredProviders(t *testing.T) {
}
got = ai.RegisteredProviders("stream")
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
want = []string{"openai"}
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, Stream: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: 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, Stream: true}) {
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: 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{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
want := []string{"openai", "test-stream"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
+1 -3
View File
@@ -22,14 +22,12 @@ 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 {
@@ -121,7 +119,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 openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: groq provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package groq
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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)
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)
}
}
-117
View File
@@ -1,117 +0,0 @@
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()
}
+1 -3
View File
@@ -22,14 +22,12 @@ 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 {
@@ -121,7 +119,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 openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: mistral provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package mistral
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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)
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)
}
}
+10 -14
View File
@@ -93,10 +93,9 @@ 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
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
// 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
@@ -122,16 +121,13 @@ 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
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
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
}
type runInfoKey struct{}
-729
View File
@@ -1,729 +0,0 @@
// Package ollama implements the Ollama model provider.
//
// Ollama runs open-weight models locally (or via Ollama Cloud). This
// provider supports two API styles:
//
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
//
// The provider auto-detects which style to use based on the base URL.
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
//
// Usage (local):
//
// import _ "go-micro.dev/v6/ai/ollama"
//
// m := ai.New("ollama",
// ai.WithBaseURL("http://localhost:11434"),
// ai.WithModel("llama3.2"),
// )
//
// Usage (Ollama Cloud):
//
// m := ai.New("ollama",
// ai.WithBaseURL("https://ollama.com/v1"),
// ai.WithAPIKey("your-key"),
// ai.WithModel("gpt-oss:120b"),
// )
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
type Provider struct {
opts ai.Options
// cloudOverride forces cloud mode for testing. When true, the provider
// uses the OpenAI-compatible endpoint regardless of the base URL.
cloudOverride bool
}
// NewProvider creates a new Ollama provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama3.2"
}
if options.BaseURL == "" {
options.BaseURL = "http://localhost:11434"
}
return &Provider{opts: options}
}
// Init initializes the provider with options.
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
// Options returns the provider options.
func (p *Provider) Options() ai.Options { return p.opts }
// String returns the provider name.
func (p *Provider) String() string { return "ollama" }
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
// the native /api/chat.
func (p *Provider) isCloud() bool {
if p.cloudOverride {
return true
}
return strings.Contains(p.opts.BaseURL, "ollama.com")
}
// chatPath returns the API endpoint path for chat completions.
func (p *Provider) chatPath() string {
if p.isCloud() {
return "/v1/chat/completions"
}
return "/api/chat"
}
// streamPath returns the API endpoint path for streaming chat.
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
// Local Ollama uses /api/chat with stream:true.
func (p *Provider) streamPath() string {
return p.chatPath()
}
// Generate generates a response from the Ollama model.
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if p.isCloud() {
return p.generateOpenAI(ctx, req)
}
return p.generateNative(ctx, req)
}
// Stream generates a streaming response.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if p.isCloud() {
return p.streamOpenAI(ctx, req)
}
return p.streamNative(ctx, req)
}
// ---------------------------------------------------------------------------
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
// ---------------------------------------------------------------------------
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
if err != nil {
return nil, err
}
// No tool calls or no handler — return as-is.
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop.
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
"tool_calls": rawMsg.toolCalls,
})
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"tool_call_id": pendingCalls[i].ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["max_tokens"] = p.opts.MaxTokens
}
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
"tool_calls": followUpRaw.toolCalls,
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{
Reply: choice.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.Usage.PromptTokens,
OutputTokens: chatResp.Usage.CompletionTokens,
TotalTokens: chatResp.Usage.TotalTokens,
},
}
var rawToolCalls []map[string]any
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: choice.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := buildOpenAIMessages(req)
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, "/") + p.streamPath()
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")
if p.opts.APIKey != "" {
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 &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
func buildOpenAIMessages(req *ai.Request) []map[string]any {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(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})
}
return messages
}
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
type sseStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *sseStream) 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 *sseStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: localhost:11434/api/chat)
// ---------------------------------------------------------------------------
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(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": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
resp, rawMsg, err := p.callNative(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
})
if len(rawMsg.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
}
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
})
if len(followUpRaw.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
}
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{
Reply: chatResp.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.PromptEvalCount,
OutputTokens: chatResp.EvalCount,
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
},
}
var rawToolCalls []map[string]any
for _, tc := range chatResp.Message.ToolCalls {
var input map[string]any
switch v := tc.Function.Arguments.(type) {
case string:
if err := json.Unmarshal([]byte(v), &input); err != nil {
input = map[string]any{}
}
case map[string]any:
input = v
default:
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: chatResp.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(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,
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": 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, "/") + p.streamPath()
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")
if p.opts.APIKey != "" {
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 &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
type ndjsonStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *ndjsonStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Done {
return nil, io.EOF
}
if chunk.Message.Content != "" {
return &ai.Response{Reply: chunk.Message.Content}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *ndjsonStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// rawChatMessage holds the raw assistant content and tool calls for
// follow-up messages.
type rawChatMessage struct {
content string
toolCalls []map[string]any
}
-333
View File
@@ -1,333 +0,0 @@
package ollama
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
// ---------------------------------------------------------------------------
// Provider basics
// ---------------------------------------------------------------------------
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "ollama" {
t.Errorf("Expected 'ollama', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "llama3.2" {
t.Errorf("Expected default model 'llama3.2', got '%s'", opts.Model)
}
if opts.BaseURL != "http://localhost:11434" {
t.Errorf("Expected default base URL 'http://localhost:11434', got '%s'", opts.BaseURL)
}
}
func TestProvider_IsCloud(t *testing.T) {
local := NewProvider(ai.WithBaseURL("http://localhost:11434"))
if local.isCloud() {
t.Error("localhost should not be cloud")
}
cloud := NewProvider(ai.WithBaseURL("https://ollama.com/v1"))
if !cloud.isCloud() {
t.Error("ollama.com should be cloud")
}
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: /api/chat)
// ---------------------------------------------------------------------------
func TestNative_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("Expected /api/chat, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "Hello from local Ollama!"},
"done": true,
"prompt_eval_count": 10,
"eval_count": 5
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from local Ollama!" {
t.Errorf("Expected 'Hello from local Ollama!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestNative_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Seoul\"}"}}]
},
"done": true
}`))
} else {
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "The weather in Seoul is sunny."},
"done": true
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "get_weather" {
t.Errorf("Expected tool 'get_weather', got '%s'", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"temp": 22, "condition": "sunny"}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("llama3.2"),
ai.WithToolHandler(handler),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "What's the weather?",
Tools: []ai.Tool{{
Name: "get_weather",
Description: "Get weather",
Properties: map[string]any{"city": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "The weather in Seoul is sunny." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestNative_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":{"role":"assistant","content":"Hello"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":" world"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":""},"done":true}` + "\n"))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello world" {
t.Errorf("Expected 'Hello world', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Cloud mode (Ollama Cloud: /v1/chat/completions)
// ---------------------------------------------------------------------------
func TestCloud_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
"choices": [{"message": {"role": "assistant", "content": "Hello from Ollama Cloud!"}}]
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("gemma4:31b-cloud"), ai.WithAPIKey("test-key"))
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from Ollama Cloud!" {
t.Errorf("Expected 'Hello from Ollama Cloud!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestCloud_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call_1", "function": {"name": "search", "arguments": "{\"query\":\"go interfaces\"}"}}]
}}]
}`))
} else {
w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "Go interfaces are implicit."}}]
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"results": ["Go interfaces are implicit"]}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
ai.WithToolHandler(handler),
)
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Search for Go interfaces",
Tools: []ai.Tool{{
Name: "search",
Description: "Search the knowledge base",
Properties: map[string]any{"query": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "Go interfaces are implicit." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestCloud_Stream(t *testing.T) {
srv := 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\":\"Hello\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" cloud\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
)
p.cloudOverride = true
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello cloud" {
t.Errorf("Expected 'Hello cloud', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
func TestProvider_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "model not found"}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("nonexistent"))
_, err := p.Generate(context.Background(), &ai.Request{Prompt: "Hi"})
if err == nil {
t.Error("Expected error on API failure")
}
if !strings.Contains(err.Error(), "API error") {
t.Errorf("Expected 'API error' in message, got '%s'", err.Error())
}
}
+6 -26
View File
@@ -98,9 +98,6 @@ 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
@@ -162,13 +159,9 @@ func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.Gener
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
"model": p.opts.Model,
"messages": messages,
"stream": true,
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
@@ -220,27 +213,14 @@ 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 != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta.Content == "" {
continue
}
// 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
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if err := s.scanner.Err(); err != nil {
return nil, err
-10
View File
@@ -16,8 +16,6 @@ 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
@@ -93,11 +91,3 @@ 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
}
}
+10 -31
View File
@@ -13,12 +13,6 @@ 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
@@ -119,7 +113,16 @@ 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 := retryBackoff(err, attempt, policy.Backoff)
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
}
t := time.NewTimer(backoff)
select {
case <-ctx.Done():
@@ -133,30 +136,6 @@ 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 {
-40
View File
@@ -139,14 +139,6 @@ 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
@@ -189,35 +181,3 @@ 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)
}
}
-307
View File
@@ -1,307 +0,0 @@
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)
}
}
+1 -3
View File
@@ -22,14 +22,12 @@ 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 {
@@ -121,7 +119,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 openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: together provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package together
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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)
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)
}
}
-46
View File
@@ -625,49 +625,3 @@ Scopes provide fine-grained access control over which tokens can call which serv
The gateway's scope system uses `auth.Account` from the go-micro framework. Scopes on accounts are the same `[]string` field used by the framework's `auth.Rules` and `wrapper/auth` package. The gateway stores scope requirements in the default store under `endpoint-scopes/<service>.<endpoint>` keys and checks them on every HTTP request.
For service-level (RPC) auth within the go-micro mesh, use the `wrapper/auth` package which provides `auth.Rules` with priority-based access control. See the [auth wrapper documentation](../../wrapper/auth/README.md) for details.
## Self-improving loop (`micro loop`)
Turn a repository into a self-improving one: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage — gated by CI. This is the
same loop that maintains go-micro itself, generalized so any repo (and any
@mention-driven agent) can use it.
```bash
micro loop init # scaffold the loop into the current repo
micro loop verify # check a repo is wired correctly
```
`micro loop init` writes three workflows and a queue:
| Role | File | What it does |
|------|------|--------------|
| Planner | `.github/workflows/loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
| Builder | `.github/workflows/loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
| Triage | `.github/workflows/loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
Direction lives in `.github/loop/NORTH_STAR.md` — edit it to steer the loop.
Common flags:
```bash
micro loop init \
--agent @codex \
--token-secret LOOP_TOKEN \
--branch main \
--ci-workflow CI
```
- `--agent`: how the workflows summon the agent (an `@mention`)
- `--token-secret`: repo secret holding the driving user PAT
- `--branch`: base branch for the loop's PRs
- `--ci-workflow`: `name:` of the CI workflow triage watches
Two things the CLI can't do for you (and `micro loop verify` reminds you of):
1. **Add the token secret.** The agent ignores `@mentions` from the
`github-actions` bot, so dispatch posts as a real user via a PAT stored in
the `--token-secret` repo secret. The workflows no-op until it's set.
2. **Set branch protection.** Require the CI checks with **0 approving reviews**
so the builder's native auto-merge lands PRs the moment CI is green — that
green-CI gate is the loop's only safety mechanism, so keep the suite strong.
-8
View File
@@ -36,14 +36,6 @@ func init() {
Name: "agent",
Usage: "Manage AI agents",
Subcommands: []*cli.Command{
{
Name: "preflight",
Aliases: []string{"doctor"},
Usage: "Check local prerequisites before the first provider-backed agent",
Action: func(c *cli.Context) error {
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
},
},
{
Name: "list",
Usage: "List registered agents",
-140
View File
@@ -1,140 +0,0 @@
package agent
import (
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"go-micro.dev/v6/cmd"
)
type preflightCheck struct {
Name string
OK bool
Detail string
Fix string
}
type preflightDeps struct {
lookPath func(string) (string, error)
commandOutput func(string, ...string) ([]byte, error)
executable func() (string, error)
version func() string
getenv func(string) string
listen func(string, string) (net.Listener, error)
}
func defaultPreflightDeps() preflightDeps {
return preflightDeps{
lookPath: exec.LookPath,
commandOutput: func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() },
executable: os.Executable,
version: func() string { return cmd.App().Version },
getenv: os.Getenv,
listen: net.Listen,
}
}
func runAgentPreflight(w io.Writer, deps preflightDeps) error {
checks := agentPreflightChecks(deps)
failures := 0
fmt.Fprintln(w, "First-agent preflight")
for _, check := range checks {
mark := "✓"
if !check.OK {
mark = "✗"
failures++
}
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
}
if failures > 0 {
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
}
fmt.Fprintln(w, "\nReady for the first-agent walkthrough: micro run, then open http://localhost:8080/agent or use micro chat.")
return nil
}
func agentPreflightChecks(deps preflightDeps) []preflightCheck {
if deps.lookPath == nil {
deps.lookPath = exec.LookPath
}
if deps.commandOutput == nil {
deps.commandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() }
}
if deps.executable == nil {
deps.executable = os.Executable
}
if deps.version == nil {
deps.version = func() string { return cmd.App().Version }
}
if deps.getenv == nil {
deps.getenv = os.Getenv
}
if deps.listen == nil {
deps.listen = net.Listen
}
checks := []preflightCheck{checkGoToolchain(deps), checkMicroBinary(deps), checkProviderKey(deps), checkPortAvailable(deps, ":8080", "micro run gateway and /agent playground")}
return checks
}
func checkGoToolchain(deps preflightDeps) preflightCheck {
path, err := deps.lookPath("go")
if err != nil {
return preflightCheck{Name: "Go toolchain", Fix: "Install Go 1.24 or newer and ensure go is on PATH."}
}
out, err := deps.commandOutput("go", "version")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully."}
}
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", firstLine(out), path)}
}
func checkMicroBinary(deps preflightDeps) preflightCheck {
exe, err := deps.executable()
if err != nil || exe == "" {
return preflightCheck{Name: "micro binary", Fix: "Install the micro CLI or run this check through go run ./cmd/micro agent preflight."}
}
version := deps.version()
if version == "" {
version = "version unavailable"
}
return preflightCheck{Name: "micro binary", OK: true, Detail: fmt.Sprintf("%s (%s)", version, exe)}
}
func checkProviderKey(deps preflightDeps) preflightCheck {
keys := []string{"MICRO_AI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "ATLASCLOUD_API_KEY"}
var found []string
for _, k := range keys {
if deps.getenv(k) != "" {
found = append(found, k)
}
}
if len(found) == 0 {
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents."}
}
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
}
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
ln, err := deps.listen("tcp", addr)
if err != nil {
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " or run micro run --address with a free port."}
}
_ = ln.Close()
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
}
func firstLine(b []byte) string {
s := strings.TrimSpace(string(b))
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
-78
View File
@@ -1,78 +0,0 @@
package agent
import (
"bytes"
"errors"
"net"
"strings"
"testing"
)
type stubListener struct{}
func (stubListener) Accept() (net.Conn, error) { return nil, errors.New("closed") }
func (stubListener) Close() error { return nil }
func (stubListener) Addr() net.Addr { return stubAddr(":8080") }
type stubAddr string
func (a stubAddr) Network() string { return "tcp" }
func (a stubAddr) String() string { return string(a) }
func TestRunAgentPreflightPassesWithKeyAndFreePort(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.24.0 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
if err := runAgentPreflight(&out, deps); err != nil {
t.Fatalf("runAgentPreflight() error = %v", err)
}
got := out.String()
for _, want := range []string{"First-agent preflight", "✓ Go toolchain", "✓ micro binary", "✓ provider API key", "✓ local port :8080", "Ready for the first-agent walkthrough"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "", errors.New("not found") },
executable: func() (string, error) { return "", errors.New("unknown") },
getenv: func(key string) string { return "" },
listen: func(network, address string) (net.Listener, error) { return nil, errors.New("in use") },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "Install Go 1.24", "✗ micro binary", "✗ provider API key", "ANTHROPIC_API_KEY", "✗ local port :8080", "micro run --address"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestFirstLine(t *testing.T) {
if got := firstLine([]byte("one\ntwo")); got != "one" {
t.Fatalf("firstLine() = %q", got)
}
if got := firstLine([]byte(" single ")); got != "single" {
t.Fatalf("firstLine() = %q", got)
}
}
+35 -86
View File
@@ -43,9 +43,6 @@ func Deploy(c *cli.Context) error {
}
target, remotePath := resolveDeployTarget(c, target, cfg)
if c.Bool("dry-run") {
return printDeployPlan(c, target, cfg, remotePath)
}
return deploySSH(c, target, cfg, remotePath)
}
@@ -99,81 +96,6 @@ func showDeployTargets(cfg *config.Config) error {
return fmt.Errorf("%s", sb.String())
}
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 == "" {
@@ -199,10 +121,19 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath str
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
// Early validation: resolve services before SSH checks.
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
// 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)
}
}
// Step 1: Check SSH connectivity
@@ -222,6 +153,28 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath str
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")
@@ -540,10 +493,6 @@ 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",
},
},
})
}
-47
View File
@@ -17,7 +17,6 @@ func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
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)
}
@@ -119,49 +118,3 @@ deploy prod
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)
}
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/urfave/cli/v2"
microcmd "go-micro.dev/v6/cmd"
)
func TestFirstAgentWalkthroughCLIBoundaries(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{"new", "run", "chat", "inspect", "agent"} {
if !commands[want] {
t.Fatalf("first-agent walkthrough missing %q command", want)
}
}
if !subcommands["agent"]["preflight"] {
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
}
if !subcommands["inspect"]["agent"] {
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
}
chat := commandByName(t, "chat")
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
}
}
func commandByName(t *testing.T, name string) *cli.Command {
t.Helper()
for _, command := range microcmd.DefaultCmd.App().Commands {
if command.Name == name {
return command
}
}
t.Fatalf("missing command %q", name)
return nil
}
-169
View File
@@ -1,169 +0,0 @@
// 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]
}
-64
View File
@@ -1,64 +0,0 @@
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)
}
}
-300
View File
@@ -1,300 +0,0 @@
// Package loop implements the 'micro loop' command, which scaffolds and
// verifies an autonomous improvement loop for a repository.
//
// The loop is a set of GitHub Actions workflows — a planner that keeps a ranked
// queue, a builder that builds the top item as a single-concern PR, and a triage
// pass that turns CI failures into fix issues — that dispatch a coding agent by
// @mention on a fresh tracking issue each run. `micro loop init` writes those
// workflows (plus a NORTH_STAR and PRIORITIES queue) into a repo; `micro loop
// verify` checks that a repo is wired correctly.
package loop
import (
"bytes"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
//go:embed templates/*
var templatesFS embed.FS
// config is the substitution surface for the workflow templates. It is the
// whole "config vs core" boundary: the workflows are the reusable core, these
// fields are what a given repo tunes.
type config struct {
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // name: of the CI workflow triage watches for failures
PlannerCron string // cron for the planner
BuilderCron string // cron for the builder
}
// generated workflow files: template name -> destination (relative to repo root).
var workflows = map[string]string{
"templates/loop-planner.yml.tmpl": ".github/workflows/loop-planner.yml",
"templates/loop-builder.yml.tmpl": ".github/workflows/loop-builder.yml",
"templates/loop-triage.yml.tmpl": ".github/workflows/loop-triage.yml",
}
// static (non-templated) docs: template name -> destination.
var docs = map[string]string{
"templates/NORTH_STAR.md": ".github/loop/NORTH_STAR.md",
"templates/PRIORITIES.md": ".github/loop/PRIORITIES.md",
}
func init() {
cmd.Register(&cli.Command{
Name: "loop",
Usage: "Scaffold an autonomous improvement loop for a repository",
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage gated by CI.
The loop has three roles:
planner keeps a ranked queue in .github/loop/PRIORITIES.md
builder builds the top open item as a single-concern PR (auto-merged on green CI)
triage turns CI failures into scoped fix issues back into the queue
Direction lives in .github/loop/NORTH_STAR.md edit it to steer the loop.
Examples:
# Scaffold the loop into the current repo
micro loop init
# Customize the agent, token secret, base branch, and CI workflow name
micro loop init --agent @codex --token-secret LOOP_TOKEN \
--branch main --ci-workflow CI
# Check that a repo is wired correctly
micro loop verify`,
Subcommands: []*cli.Command{
{
Name: "init",
Usage: "Scaffold the loop workflows and queue into a repo",
Flags: []cli.Flag{
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "name: of the CI workflow triage watches for failures", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
},
Action: runInit,
},
{
Name: "verify",
Usage: "Verify a repo is wired for the loop",
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
Action: runVerify,
},
},
})
}
func runInit(c *cli.Context) error {
dir := c.String("dir")
cfg := config{
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: c.String("ci-workflow"),
PlannerCron: c.String("planner-cron"),
BuilderCron: c.String("builder-cron"),
}
if cfg.DefaultBranch == "" {
cfg.DefaultBranch = detectDefaultBranch(dir)
}
if !strings.HasPrefix(cfg.AgentMention, "@") {
cfg.AgentMention = "@" + cfg.AgentMention
}
if err := scaffold(dir, cfg, c.Bool("force")); err != nil {
return err
}
printNextSteps(cfg)
return nil
}
// scaffold renders the workflow templates and writes the loop files into dir.
// Static docs (NORTH_STAR, PRIORITIES) are never clobbered even with force, so
// re-running init can't wipe curated direction or a hand-tuned queue.
func scaffold(dir string, cfg config, force bool) error {
for tmplName, dest := range workflows {
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
}
for tmplName, dest := range docs {
full := filepath.Join(dir, dest)
if fileExists(full) {
fmt.Printf(" kept %s (already exists)\n", dest)
continue
}
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return err
}
if err := writeFile(full, b, true); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
}
return nil
}
// verifyState reports what's wrong with dir's loop setup: warnings are
// non-fatal, missing are required files that aren't present.
func verifyState(dir string) (warnings, missing []string) {
for _, dest := range workflows {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
for _, dest := range docs {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
// The loop is only as good as its gate: warn if there's no non-loop
// workflow to serve as CI.
if !hasCIWorkflow(dir) {
warnings = append(warnings, "no non-loop workflow found in .github/workflows — the loop needs a CI gate (build/test/lint) to merge safely")
}
return warnings, missing
}
func runVerify(c *cli.Context) error {
dir := c.String("dir")
warnings, missing := verifyState(dir)
for _, m := range missing {
fmt.Printf(" MISSING %s\n", m)
}
for _, w := range warnings {
fmt.Printf(" WARN %s\n", w)
}
if len(missing) > 0 {
return fmt.Errorf("loop is not fully scaffolded (%d file(s) missing) — run `micro loop init`", len(missing))
}
fmt.Println(" OK loop workflows and queue are present")
fmt.Println()
fmt.Println("Reminders the CLI can't check:")
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
if len(warnings) > 0 {
return fmt.Errorf("%d warning(s) — see above", len(warnings))
}
return nil
}
func render(tmplName string, cfg config) ([]byte, error) {
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return nil, err
}
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
// untouched — only << >> placeholders are substituted.
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
if err != nil {
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, cfg); err != nil {
return nil, fmt.Errorf("render %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func writeFile(path string, content []byte, force bool) error {
if fileExists(path) && !force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
// not one of the loop's own (i.e. a plausible CI gate).
func hasCIWorkflow(dir string) bool {
entries, err := os.ReadDir(filepath.Join(dir, ".github", "workflows"))
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "loop-") {
continue
}
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return true
}
}
return false
}
// detectDefaultBranch best-effort resolves the repo's default branch, falling
// back to "main".
func detectDefaultBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
if err == nil {
ref := strings.TrimSpace(string(out))
if i := strings.LastIndex(ref, "/"); i >= 0 {
ref = ref[i+1:]
}
if ref != "" {
return ref
}
}
return "main"
}
func printNextSteps(cfg config) {
fmt.Printf(`
Loop scaffolded. Next steps (the CLI can't do these for you):
1. Edit .github/loop/NORTH_STAR.md the direction the loop aligns to.
Seed .github/loop/PRIORITIES.md with a few real items.
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
requests + issues write) for an account the agent (%s) responds to.
The workflows no-op until this secret exists.
3. Ensure a CI workflow named %q exists and that branch protection on %q
requires its checks with 0 approving reviews that green-CI gate is
what lets the builder auto-merge safely.
4. Commit these files, then trigger a run:
Actions "Loop: Planner" / "Loop: Builder" Run workflow.
Verify anytime with: micro loop verify
`, cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
}
-107
View File
@@ -1,107 +0,0 @@
package loop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenderProducesValidPlaceholderFreeYAML(t *testing.T) {
cfg := config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
PlannerCron: "0 * * * *",
BuilderCron: "30 * * * *",
}
for tmplName := range workflows {
rendered, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", tmplName, err)
}
s := string(rendered)
// No unresolved substitution delimiters should remain...
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
t.Errorf("%s still contains << >> placeholders after render", tmplName)
}
// ...but GitHub Actions' own ${{ }} expressions must survive verbatim.
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", tmplName)
}
// The configured values must be substituted in.
if !strings.Contains(s, "@codex") {
t.Errorf("%s missing agent mention", tmplName)
}
// Structural sanity: a workflow needs these top-level keys.
for _, key := range []string{"name:", "on:", "jobs:"} {
if !strings.Contains(s, key) {
t.Errorf("%s missing top-level %q", tmplName, key)
}
}
}
}
func TestInitThenVerify(t *testing.T) {
dir := t.TempDir()
// A non-loop workflow so verify's CI-gate check passes.
mustWrite(t, filepath.Join(dir, ".github/workflows/ci.yml"), "name: CI\n")
if err := scaffold(dir, config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
PlannerCron: "0 * * * *",
BuilderCron: "30 * * * *",
}, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
for _, dest := range workflows {
if !fileExists(filepath.Join(dir, dest)) {
t.Errorf("expected %s to be written", dest)
}
}
for _, dest := range docs {
if !fileExists(filepath.Join(dir, dest)) {
t.Errorf("expected %s to be written", dest)
}
}
// A second scaffold without --force must fail on an existing workflow.
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err == nil {
t.Error("expected second scaffold without --force to fail")
}
}
func TestVerifyMissingFilesFails(t *testing.T) {
dir := t.TempDir()
if _, missing := verifyState(dir); len(missing) == 0 {
t.Error("expected missing files in an empty dir")
}
}
func TestVerifyWarnsWithoutCIGate(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Only loop-* workflows exist → no CI gate.
if hasCIWorkflow(dir) {
t.Error("expected no CI gate when only loop-* workflows are present")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
-23
View File
@@ -1,23 +0,0 @@
# North Star
> **Edit this file.** It is the single source of direction the loop aligns every
> increment to. The planner ranks work against it; the builder builds toward it.
> Be concrete — vague direction produces vague increments.
## Mission
<One or two sentences: the problem this repository solves and who it's for.>
## Right now
<The current priority — what "better" means this month. The planner weights the
queue toward this.>
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI, not a human review — keep the test/lint suite strong,
because the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
-16
View File
@@ -1,16 +0,0 @@
# Priorities
A single ranked queue, highest-value first. Each item links a scoped issue the
loop can build and CI can verify. The **planner** keeps this current; the
**builder** takes the top item whose issue is still open.
<!--
Seed this with a few real items to give the loop a running start, e.g.:
1. Add retry with backoff to the HTTP client — #123
2. Document the config file format — #124
3. Fix flaky timeout in the cache tests — #125
The planner will re-rank, drop completed items, and file issues for new gaps.
Reorder or edit this file at any time to redirect the loop.
-->
@@ -1,46 +0,0 @@
name: "Loop: Builder"
# Generated by `micro loop init`. The GENERATOR of the loop: each run it opens a
# fresh tracking issue and dispatches the agent to build the top open item from
# .github/loop/PRIORITIES.md as a single-concern PR, then enables native
# auto-merge so the PR lands once the required CI checks pass. Branch protection
# (required checks, 0 approvals) is the gate — there is no merge sweep.
#
# A FRESH issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch
# and only the first PR opens. Gated on << .TokenSecret >> (see loop-planner.yml).
on:
workflow_dispatch: {}
schedule:
- cron: "<< .BuilderCron >>"
permissions:
issues: write
concurrency:
group: loop-builder
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open an increment issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: build increment #$RUN_NUMBER" \
--body "Autonomous build increment. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching the builder."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Build one increment for this repository, aligned to .github/loop/NORTH_STAR.md. PICK THE WORK: take the highest-ranked item in .github/loop/PRIORITIES.md whose linked issue is still OPEN — that is your task and its issue is the one you close. If the queue is empty or every item's issue is closed, pick the single highest-value improvement yourself. Implement it, then VERIFY the project builds, tests, and lints (use the commands documented in the README or the CI workflow). Open the PR YOURSELF from the shell — do NOT use a make_pr tool (it may be a no-op stub): \`git switch -c loop/increment-$ISSUE_NUM\`, \`git push -u origin loop/increment-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this tracker>\"\`, then \`gh pr merge --squash --auto --delete-branch\` so it lands on green CI. One concern per PR; stay out of breaking public API and brand/positioning copy."
@@ -1,48 +0,0 @@
name: "Loop: Planner"
# Generated by `micro loop init`. Part of an autonomous improvement loop:
# a PLANNER (this file) keeps a ranked queue, a BUILDER builds the top item,
# and CI + a TRIAGE pass are the evaluator. The loop dispatches a coding agent
# by @mention on a fresh tracking issue each run.
#
# Gated on the << .TokenSecret >> secret: the agent ignores @mentions from the
# github-actions bot, so the dispatch must post as a real user (a PAT). Until
# that secret is set the workflow runs but no-ops. Direction lives in
# .github/loop/NORTH_STAR.md; the ranked queue in .github/loop/PRIORITIES.md.
on:
workflow_dispatch: {}
schedule:
- cron: "<< .PlannerCron >>"
permissions:
issues: write
concurrency:
group: loop-planner
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a planning issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping."
echo "The agent ignores @mentions from the github-actions bot, so a"
echo "user PAT is required. Add a << .TokenSecret >> secret to activate."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: planning review #$RUN_NUMBER" \
--body "Autonomous planning pass. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching the planner."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Act as the planner for this repository. (1) Read .github/loop/NORTH_STAR.md for direction, then assess current state — recently merged PRs and open issues — so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) Maintain a SINGLE ranked queue in .github/loop/PRIORITIES.md, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap with no issue, file one: \`gh issue create --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. (3) If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c loop/planner-$ISSUE_NUM\`, \`git push -u origin loop/planner-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate, just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking or architectural changes yourself — surface those as notes for a human. Open the PR yourself from the shell with gh; do not use a make_pr tool."
@@ -1,46 +0,0 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("<< .CIWorkflow >>") fails on a non-PR run, this dispatches the
# agent to root-cause the failure and file scoped fix issues back into the
# planner's queue — so failures become fixes with no human in the middle, short
# of a decision that is genuinely a human's. Gated on << .TokenSecret >>.
on:
workflow_run:
workflows: ["<< .CIWorkflow >>"]
types: [completed]
permissions:
issues: write
concurrency:
group: loop-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: File a triage issue and dispatch the agent
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID" \
--body "The '<< .CIWorkflow >>' workflow failed: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"<< .AgentMention >> Triage the failed CI run at $RUN_URL. Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment 'recurred' there instead of filing a duplicate. For each genuine, self-contained defect, file a scoped issue (\`gh issue create --title \"<scoped fix>\" --body \"<root cause, where, acceptance>\"\`) so the planner/builder can pick it up and the next CI run verifies it. IGNORE transient flakes (network blips, provider outages, timeouts with no code cause). Anything needing a breaking or architectural change: label it needs-human and describe it — do NOT auto-file it as a routine fix. Close this issue (\`gh issue close $ISSUE_NUM\`) when triage is done."
-2
View File
@@ -12,8 +12,6 @@ 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/loop"
_ "go-micro.dev/v6/cmd/micro/mcp"
_ "go-micro.dev/v6/cmd/micro/resource"
_ "go-micro.dev/v6/cmd/micro/run"
+2 -5
View File
@@ -498,13 +498,10 @@ 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 != "" {
// 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(" MCP \033[36mhttp://localhost%s\033[0m\n", mcpAddr)
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", mcpAddr)
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
}
}
+1 -21
View File
@@ -19,7 +19,7 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
}
}
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy"} {
for _, want := range []string{"run", "chat", "flow"} {
if !commands[want] {
t.Fatalf("missing %q command", want)
}
@@ -27,24 +27,4 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
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")
}
}
+1 -1
View File
@@ -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/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -1
View File
@@ -102,4 +102,4 @@ pytest tests/integration/ -v
## Questions?
- GitHub Discussions: https://github.com/micro/go-micro/discussions
- Discord: https://discord.gg/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -1
View File
@@ -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/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -5
View File
@@ -19,11 +19,7 @@ func NewStream(opts ...Option) (Stream, error) {
for _, o := range opts {
o(&options)
}
st := options.Store
if st == nil {
st = store.NewMemoryStore()
}
return &mem{store: st}, nil
return &mem{store: store.NewMemoryStore()}, nil
}
type subscriber struct {
+2 -16
View File
@@ -1,25 +1,11 @@
package events
import (
"time"
import "time"
"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 Options struct{}
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
+4 -5
View File
@@ -80,11 +80,10 @@ 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 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`
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
## Coming Soon
-45
View File
@@ -1,45 +0,0 @@
# 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`.
-88
View File
@@ -1,88 +0,0 @@
// 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
}
-48
View File
@@ -1,48 +0,0 @@
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()
}
-8
View File
@@ -34,11 +34,3 @@ 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.
-246
View File
@@ -1,246 +0,0 @@
// Agent Ollama — a self-contained agent powered by Ollama Cloud.
//
// This example demonstrates the full harness loop — service tools, custom
// tools, agent memory, guardrails, and streaming — using the Ollama
// provider with gpt-oss:120b on Ollama Cloud.
//
// It creates a "knowledge" service with two endpoints (Add, Search) that
// the agent discovers as tools, plus a custom "current_time" tool. The
// agent answers natural-language questions by calling those tools.
//
// Run (Ollama Cloud — default):
//
// OLLAMA_API_KEY=your-key go run main.go
//
// Run (local Ollama):
//
// OLLAMA_BASE_URL=http://localhost:11434 \
// OLLAMA_MODEL=llama3.2 \
// go run main.go
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"go-micro.dev/v6"
"go-micro.dev/v6/agent"
)
// ---------------------------------------------------------------------------
// knowledge service — a tiny in-memory knowledge base
// ---------------------------------------------------------------------------
type KnowledgeEntry struct {
ID string `json:"id" description:"Unique entry identifier"`
Topic string `json:"topic" description:"Topic or category"`
Content string `json:"content" description:"The knowledge content"`
}
type AddKnowledgeRequest struct {
Topic string `json:"topic" description:"Topic or category (required)"`
Content string `json:"content" description:"The knowledge content (required)"`
}
type AddKnowledgeResponse struct {
Entry *KnowledgeEntry `json:"entry" description:"The added entry"`
}
type SearchKnowledgeRequest struct {
Topic string `json:"topic,omitempty" description:"Filter by topic (optional)"`
Keyword string `json:"keyword,omitempty" description:"Search keyword in content (optional)"`
}
type SearchKnowledgeResponse struct {
Entries []*KnowledgeEntry `json:"entries" description:"Matching entries"`
}
type KnowledgeService struct {
mu sync.RWMutex
entries []*KnowledgeEntry
nextID int
}
// Add stores a new knowledge entry.
//
// @example {"topic": "go", "content": "Go interfaces are implicit."}
func (s *KnowledgeService) Add(ctx context.Context, req *AddKnowledgeRequest, rsp *AddKnowledgeResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
e := &KnowledgeEntry{
ID: fmt.Sprintf("kb-%d", s.nextID),
Topic: req.Topic,
Content: req.Content,
}
s.entries = append(s.entries, e)
rsp.Entry = e
return nil
}
// Search finds knowledge entries by topic or keyword.
//
// @example {"topic": "go"}
// @example {"keyword": "interface"}
func (s *KnowledgeService) Search(ctx context.Context, req *SearchKnowledgeRequest, rsp *SearchKnowledgeResponse) error {
s.mu.RLock()
defer s.mu.RUnlock()
for _, e := range s.entries {
if req.Topic != "" && !strings.EqualFold(e.Topic, req.Topic) {
continue
}
if req.Keyword != "" && !strings.Contains(strings.ToLower(e.Content), strings.ToLower(req.Keyword)) {
continue
}
rsp.Entries = append(rsp.Entries, e)
}
return nil
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func main() {
// Ollama Cloud is the default. Override with env vars for local Ollama.
baseURL := os.Getenv("OLLAMA_BASE_URL")
if baseURL == "" {
baseURL = "https://ollama.com/v1"
}
model := os.Getenv("OLLAMA_MODEL")
if model == "" {
model = "gpt-oss:120b"
}
apiKey := os.Getenv("OLLAMA_API_KEY")
fmt.Println("╔══════════════════════════════════════════╗")
fmt.Println("║ Ollama-Powered Go Micro Agent ║")
fmt.Println("╚══════════════════════════════════════════╝")
fmt.Println()
fmt.Printf(" Ollama URL: %s\n", baseURL)
fmt.Printf(" Model: %s\n", model)
if apiKey != "" {
fmt.Printf(" API Key: (set)\n")
} else {
fmt.Printf(" API Key: (none — set OLLAMA_API_KEY)\n")
}
fmt.Println()
// 1. Start the knowledge service. Its handlers become agent tools.
svc := micro.NewService("knowledge")
svc.Handle(new(KnowledgeService))
go svc.Run()
// Give the service a moment to register.
time.Sleep(2 * time.Second)
// 2. Create the agent. It discovers the knowledge service endpoints
// as tools automatically, plus gets a custom "current_time" tool.
ag := micro.NewAgent("ollama-assistant",
micro.AgentServices("knowledge"),
micro.AgentPrompt(
"You are a helpful knowledge assistant. You can search and add to "+
"a knowledge base using the knowledge service tools. "+
"When asked about the current time, use the current_time tool. "+
"Be concise and factual.",
),
micro.AgentProvider("ollama"),
micro.AgentModel(model),
micro.AgentAPIKey(apiKey),
micro.AgentBaseURL(baseURL),
micro.AgentMaxSteps(10),
micro.AgentLoopLimit(3),
// Custom tool — any function, not tied to a service.
agent.WithTool(
"current_time",
"Get the current date and time in a human-readable format",
map[string]any{
"timezone": map[string]any{
"type": "string",
"description": "Optional timezone (defaults to local)",
},
},
func(ctx context.Context, input map[string]any) (string, error) {
tz, _ := input["timezone"].(string)
if tz == "" {
return time.Now().Format("2006-01-02 15:04:05 MST"), nil
}
loc, err := time.LoadLocation(tz)
if err != nil {
return "", fmt.Errorf("unknown timezone: %s", tz)
}
return time.Now().In(loc).Format("2006-01-02 15:04:05 MST"), nil
},
),
)
// 3. Seed initial knowledge via the agent's first question.
questions := []string{
"What time is it now?",
"Add a new knowledge entry: topic 'go', content 'Go interfaces are implicit — a type implements an interface by having the required methods.'",
"Add another entry: topic 'go', content 'Go is a statically typed, compiled language designed at Google.'",
"Add another entry: topic 'ai', content 'Large language models generate text by predicting the next token in a sequence.'",
"Search the knowledge base for entries about Go.",
"Search for everything in the knowledge base.",
}
fmt.Println("─── Agent Demo ───")
fmt.Println()
for i, q := range questions {
fmt.Printf("Q%d: %s\n", i+1, q)
fmt.Print("A: ")
resp, err := ag.Ask(context.Background(), q)
if err != nil {
fmt.Printf("error: %v\n", err)
fmt.Println()
continue
}
// Show tool calls the agent made.
if len(resp.ToolCalls) > 0 {
for _, tc := range resp.ToolCalls {
args, _ := json.Marshal(tc.Input)
fmt.Printf(" [tool] %s(%s)\n", tc.Name, string(args))
}
}
fmt.Println(resp.Reply)
if resp.Reply == "" && len(resp.ToolCalls) == 0 {
fmt.Println("(no response)")
}
fmt.Println()
}
// 4. Streaming demonstration.
fmt.Println("─── Streaming Demo ───")
fmt.Println()
streamQ := "Explain what Go Micro is in two sentences."
fmt.Printf("Q: %s\n", streamQ)
fmt.Print("A: ")
stream, err := ag.Stream(context.Background(), streamQ)
if err != nil {
fmt.Printf("stream error: %v\n", err)
} else {
for {
chunk, err := stream.Recv()
if err != nil {
break
}
if chunk.Reply != "" {
fmt.Print(chunk.Reply)
}
}
fmt.Println()
}
fmt.Println()
fmt.Println("Done.")
}
+5 -27
View File
@@ -1,25 +1,9 @@
# Zero-to-hero support desk
# Support desk
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.
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.
## The scenario
@@ -60,12 +44,6 @@ 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:
+1 -24
View File
@@ -1,32 +1,9 @@
package main
import (
"os"
"strings"
"testing"
)
import "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
View File
@@ -1,212 +0,0 @@
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]
}
-89
View File
@@ -1,89 +0,0 @@
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" }
-63
View File
@@ -7,7 +7,6 @@ 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
@@ -66,65 +65,3 @@ 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)
}
}
-45
View File
@@ -1,45 +0,0 @@
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
}
+2 -10
View File
@@ -76,7 +76,6 @@ 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"`
}
@@ -142,8 +141,7 @@ 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)
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
if err := f.Execute(ctx, data); err != nil {
if err := f.Execute(context.Background(), data); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
}
return nil
@@ -224,10 +222,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
}
runID := uuid.New().String()
info, _ := ai.RunInfoFrom(ctx)
info.RunID = runID
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
start := time.Now()
@@ -251,7 +246,6 @@ 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
}
@@ -267,7 +261,6 @@ 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)
}
@@ -281,7 +274,6 @@ 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
}
+14 -45
View File
@@ -16,18 +16,13 @@ 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"
AttrFlowErrorKind = "flow.error.kind"
AttrFlowVerificationStatus = "flow.verification.status"
AttrFlowVerificationNote = "flow.verification.note"
AttrFlowDispatch = "flow.dispatch"
AttrFlowTrigger = "flow.trigger"
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"
)
func (f *Flow) tracer() trace.Tracer {
@@ -38,15 +33,12 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
if f.opts.TraceProvider == nil {
return ctx, func(Run, error) {}
}
info, _ := ai.RunInfoFrom(ctx)
attrs := []attribute.KeyValue{
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
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(
@@ -55,7 +47,6 @@ 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, "")
@@ -64,51 +55,29 @@ 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, Verification, error) {
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, error) {
if f.opts.TraceProvider == nil {
return f.runStep(ctx, step, in)
}
info, _ := ai.RunInfoFrom(ctx)
attrs := []attribute.KeyValue{
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
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, verification, err := f.runStep(ctx, step, in)
out, attempts, 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, 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
return out, attempts, err
}
-26
View File
@@ -74,29 +74,3 @@ 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")
}
-60
View File
@@ -1,60 +0,0 @@
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
}
}
}
}
+21 -85
View File
@@ -52,53 +52,22 @@ func (s State) String() string { return string(s.Data) }
// returns the next state.
type StepFunc func(ctx context.Context, in State) (State, error)
// 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.
// 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.
type Step struct {
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
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
}
// 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"`
ErrorKind string `json:"error_kind,omitempty"`
VerificationStatus string `json:"verification_status,omitempty"` // passed | failed
VerificationNote string `json:"verification_note,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"`
}
// Run is the persisted record of one flow execution — what a Checkpoint
@@ -433,12 +402,7 @@ 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})
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 = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
ctx, finishSpan := f.startRunSpan(ctx, run)
var spanErr error
defer func() { finishSpan(run, spanErr) }()
@@ -461,14 +425,12 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
return run, err
}
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
out, attempts, 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
@@ -512,65 +474,40 @@ 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, Verification, error) {
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, error) {
if step.Run == nil {
return in, 0, Verification{}, fmt.Errorf("flow: step %q has no Run function", step.Name)
return in, 0, 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, lastVerification, err
return in, attempt - 1, err
}
attemptCtx := ctx
if info, ok := ai.RunInfoFrom(ctx); ok {
info.Step = step.Name
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}
}
ctx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(ctx, in)
if err == nil {
return out, attempt, lastVerification, nil
return out, attempt, 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, lastVerification, ctx.Err()
return in, attempt, ctx.Err()
}
}
}
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"
}
}
return in, retries + 1, lastErr
}
func (f *Flow) save(ctx context.Context, run Run) error {
@@ -618,7 +555,6 @@ 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" {
-34
View File
@@ -548,37 +548,3 @@ 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)
}
}
-100
View File
@@ -1,100 +0,0 @@
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
View File
@@ -1,191 +0,0 @@
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
}
-96
View File
@@ -1,96 +0,0 @@
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"])
}
}
+16 -118
View File
@@ -28,7 +28,6 @@ package a2a
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -141,11 +140,8 @@ 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)
@@ -180,8 +176,6 @@ 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.
@@ -344,17 +338,23 @@ 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, TaskResubscribe: true, InputRequired: true},
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.
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: skills,
Skills: []Skill{{
ID: "chat",
Name: "Chat",
Description: "Converse with the agent to operate its services.",
Tags: services,
}},
}
}
@@ -371,19 +371,6 @@ 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
// ---------------------------------------------------------------------------
@@ -406,17 +393,6 @@ 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 {
@@ -445,18 +421,6 @@ 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).
@@ -492,7 +456,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, invoke)
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke)
return
}
d.stream(requestContext(r.Context()), w, req, invoke)
@@ -541,7 +505,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, fallback Invoke) {
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke) {
var p sendParams
if err := json.Unmarshal(req.Params, &p); err != nil {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
@@ -554,10 +518,6 @@ 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
}
@@ -893,68 +853,6 @@ 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 {
+3 -101
View File
@@ -51,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": "task,project"}),
server.Metadata(map[string]string{"type": "agent", "services": ""}),
)
if err := pb.RegisterAgentHandler(srv, echoAgent{}); err != nil {
t.Fatalf("register agent handler: %v", err)
@@ -88,43 +88,8 @@ func TestAgentCardFromRegistry(t *testing.T) {
if card.URL != "http://gw/agents/echo" {
t.Errorf("card url = %q", card.URL)
}
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)
if card.ProtocolVersion == "" || len(card.Skills) == 0 {
t.Errorf("card missing protocolVersion or skills: %+v", card)
}
}
@@ -468,61 +433,6 @@ func TestMessageStreamChunksPropagatesCancellationAndClosesStream(t *testing.T)
}
}
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)}}
@@ -734,11 +644,3 @@ 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
}
+1 -74
View File
@@ -1,13 +1,11 @@
package a2a
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
@@ -137,77 +135,6 @@ 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{
@@ -266,7 +193,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", "input-required":
case "completed", "failed", "canceled", "rejected":
return true
}
return false
-56
View File
@@ -3,10 +3,8 @@ package a2a
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -112,57 +110,3 @@ 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)
}
}
-129
View File
@@ -1,129 +0,0 @@
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
}

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