Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2c542f65 | |||
| 9d6814f5ae | |||
| 929cd1ab1d | |||
| fe651b4d01 | |||
| 05ee980bf5 | |||
| e1d19c4cb5 | |||
| 61f873174c | |||
| 71581e9a61 | |||
| 892b4f185d | |||
| 30f53e42a7 | |||
| 016eb82a32 | |||
| a576f65b08 | |||
| 07f0d30aea | |||
| 3db9617f43 | |||
| c19e4829ba | |||
| f37ed42bf3 | |||
| 114cd7e19e | |||
| 28fc2ea340 | |||
| 38f5c13e65 | |||
| 27f5e2be52 | |||
| 1fa7584be8 | |||
| e8c632456a | |||
| 9a410cbe8b | |||
| 620374ef22 | |||
| 31556b310e | |||
| 0e67971c53 | |||
| 2af5e6ae8f | |||
| 656c4ea1e7 | |||
| 4a09b4469a | |||
| 25d66f737d | |||
| 45d68c932e | |||
| c5c08e24d8 | |||
| 8a0f4a7636 | |||
| bcf2c92931 | |||
| 57ca94a88b | |||
| ba231efe71 | |||
| f755eadd47 | |||
| 467c937873 | |||
| 3f45771840 | |||
| c9e045961d | |||
| 590aacb518 | |||
| 87eb5ce665 | |||
| ed904aa6ad | |||
| 59d028b8e1 | |||
| 19818ad638 | |||
| 993ce668b2 | |||
| 1d412fa6f5 | |||
| 271c27c44f | |||
| cefc9438c4 | |||
| af39aac297 | |||
| 41717aaa9a | |||
| 5d1137d8f9 | |||
| a14b3e79e2 | |||
| 130022e544 | |||
| 0d3f3f2be5 | |||
| ae91863343 | |||
| 72f8067e57 | |||
| 8533306e64 | |||
| 384987da9f | |||
| ddde9472fc | |||
| d7a74735c8 | |||
| 581b2120a3 | |||
| 871af7e6d3 | |||
| 9d39ed3292 | |||
| d8f711431f | |||
| 5d22cd4a5b | |||
| f1f987cb8d | |||
| 7b9c583dcf | |||
| b7f1ac6d6d | |||
| fb14ccbc17 | |||
| 5f40cae7af | |||
| 496d5b7644 | |||
| 44e62f5e8b | |||
| 18ae00dad0 | |||
| 6f9adfd375 | |||
| 8fc4f58a17 | |||
| 19ad28e04b | |||
| 319ff1972b | |||
| 03791759ef | |||
| 5e3cef7cd9 | |||
| 751530aaec | |||
| 3a9f45750b | |||
| 2def581408 | |||
| 95ee402c8b | |||
| 8aeba56839 | |||
| 42fdad439c | |||
| b45c94e201 | |||
| 23051459d8 | |||
| 4c7e6e96eb | |||
| ef21ac6494 | |||
| 53193fbbe7 | |||
| 105cc9d2fb | |||
| d369cf290c | |||
| 949542fa65 | |||
| 1421ccbea6 |
@@ -0,0 +1,51 @@
|
||||
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).
|
||||
# Where DevRel watches the public story and the increment loop ships code, the
|
||||
# architect watches the SYSTEM and runs alongside the builders: it tracks what is
|
||||
# in flight and what just merged, keeps the roadmap priorities live, and judges
|
||||
# cohesion (harness <-> framework <-> dev UX), missing pieces, and realignment.
|
||||
#
|
||||
# Its OUTPUT is the ranked queue in internal/docs/PRIORITIES.md plus an assessment
|
||||
# — NOT large refactors. Breaking public-API and architectural changes stay with
|
||||
# the human (see CONTINUOUS_IMPROVEMENT.md).
|
||||
#
|
||||
# Runs hourly, offset before the increment loop (:29) so it re-prioritizes and
|
||||
# THEN the loop builds the new top of the queue. Opens a fresh issue and
|
||||
# dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "59 * * * *" # hourly at :59, just before the :29 increment run (tunable)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: architecture-review
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open an architecture 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 "Architecture review #$RUN_NUMBER" \
|
||||
--body "Continuous architecture / harness oversight against the North Star in internal/docs/THESIS.md. Output: a re-ranked internal/docs/PRIORITIES.md (only if it changed) plus an assessment.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Auto-merge Codex PRs
|
||||
|
||||
# Part of the autonomous improvement loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
# Merges Codex's PRs once CI is green — no human involvement; CI (build, test,
|
||||
# golangci-lint, harnesses) is the only gate. Scoped to PRs that are BOTH
|
||||
# codex-labelled AND from a codex/* branch, so nothing else can auto-merge.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *" # sweep every 15 min
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: auto-merge-codex
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge green Codex PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr list --repo "$REPO" --label codex --state open \
|
||||
--json number,headRefName \
|
||||
--jq '.[] | select(.headRefName | startswith("codex/")) | .number' \
|
||||
| while read -r pr; do
|
||||
[ -z "$pr" ] && continue
|
||||
if gh pr checks "$pr" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "Checks green on #$pr — merging."
|
||||
gh pr merge "$pr" --repo "$REPO" --squash --delete-branch \
|
||||
|| echo "skip #$pr (not mergeable — conflicts?)"
|
||||
else
|
||||
echo "skip #$pr (checks pending/failing)"
|
||||
fi
|
||||
done
|
||||
@@ -5,8 +5,11 @@ name: Continuous Improvement
|
||||
#
|
||||
# A Claude Max subscription provides no API key for CI, so the loop is driven by
|
||||
# Codex rather than Claude Code: on a cadence this opens a fresh tracking issue and
|
||||
# posts an @codex instruction on it, and Codex runs one improvement increment + opens
|
||||
# a PR. (See the per-issue rationale below.)
|
||||
# posts an @codex instruction on it, and Codex runs one improvement increment, opens
|
||||
# a PR (git push + gh pr create — the make_pr tool is a no-op stub), and enables
|
||||
# GitHub auto-merge (gh pr merge --auto) so the PR lands once the required CI checks
|
||||
# pass. No separate merge sweep — branch protection + native auto-merge is the gate.
|
||||
# (See the per-issue rationale below.)
|
||||
#
|
||||
# Codex does NOT respond to comments authored by the github-actions bot, so the
|
||||
# dispatch is GATED on a CODEX_TRIGGER_TOKEN secret (a PAT for a user account Codex
|
||||
@@ -52,11 +55,11 @@ jobs:
|
||||
echo "a user account Codex follows) to activate the loop."
|
||||
exit 0
|
||||
fi
|
||||
# A unique issue per run → Codex derives a unique branch → no collisions.
|
||||
# A unique issue per run → unique codex/ branch → no collisions.
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Continuous improvement increment #$RUN_NUMBER" \
|
||||
--body "Autonomous continuous-improvement increment. North Star: internal/docs/THESIS.md; charter: internal/docs/CONTINUOUS_IMPROVEMENT.md. Tracker: #3024.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). Pick the single highest-value roadmap/issue/improvement-radar item that advances that thesis, implement it, verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`, and open a PR against master that includes \"Closes #$ISSUE_NUM\" in the description. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). PICK THE WORK FROM THE QUEUE: read internal/docs/PRIORITIES.md and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. (If PRIORITIES.md is missing or every listed item's issue is already closed, fall back to picking the single highest-value roadmap/issue/improvement-radar item yourself.) Implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this run's tracker>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
|
||||
@@ -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."
|
||||
@@ -2,8 +2,9 @@ name: Harness (E2E)
|
||||
|
||||
# Runs the end-to-end harnesses for agents, services, flows, and provider
|
||||
# conformance. The default job uses deterministic mock LLMs and needs no
|
||||
# secrets. A second job runs the same harnesses against any live providers
|
||||
# whose API key secrets are configured.
|
||||
# secrets. A second job runs the same harnesses against the live provider set and
|
||||
# fails if any selected provider secret is missing, so scheduled conformance
|
||||
# cannot silently degrade.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -26,15 +27,17 @@ jobs:
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: 0→1 scaffold contract
|
||||
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
|
||||
- name: Universe end-to-end (asserts; exits non-zero on failure)
|
||||
run: go run ./internal/harness/universe
|
||||
- name: Agent-flow harness
|
||||
run: go run ./internal/harness/agent-flow
|
||||
- name: Plan-delegate harness
|
||||
- name: 0→hero plan-delegate workflow harness
|
||||
run: go run ./internal/harness/plan-delegate
|
||||
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM, if keys present)
|
||||
name: Provider harnesses (live LLM conformance)
|
||||
runs-on: ubuntu-latest
|
||||
# Only on the daily schedule or a manual run — never automatically on
|
||||
# every push/PR, so changes don't quietly burn API credits. Trigger it
|
||||
@@ -47,7 +50,7 @@ jobs:
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Provider conformance against configured live models
|
||||
- name: Provider conformance against live models
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -56,4 +59,19 @@ jobs:
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: go run ./internal/harness/provider-conformance
|
||||
run: |
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-require-configured \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-summary-markdown provider-conformance-summary.md \
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
- name: Upload provider conformance summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: provider-conformance
|
||||
path: |
|
||||
provider-conformance-summary.json
|
||||
provider-conformance-summary.md
|
||||
provider-capabilities.md
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- "**"
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Repository agent instructions
|
||||
|
||||
These instructions apply to the entire repository.
|
||||
|
||||
## Pull requests from Codex tasks
|
||||
|
||||
When a Codex task makes repository changes and the requested outcome is a PR:
|
||||
|
||||
1. Keep the change focused on the assigned issue or prompt.
|
||||
2. Run the relevant verification commands and capture their results
|
||||
(`go build ./...`, `go test ./...`, `golangci-lint run ./...`).
|
||||
3. Check `git status --short` and review the diff before finishing.
|
||||
4. Create a uniquely-named branch under the `codex/` prefix (do not work on
|
||||
`master`, and do not use a generic name like `work`):
|
||||
|
||||
```sh
|
||||
git switch -c codex/<issue-number>-<short-slug>
|
||||
```
|
||||
5. Stage the intended files and commit on that branch.
|
||||
6. Open the pull request yourself with the GitHub CLI, which is installed in the
|
||||
environment and whose `origin` points at this repository, then enable
|
||||
auto-merge so GitHub merges it once the required CI checks pass:
|
||||
|
||||
```sh
|
||||
git push -u origin HEAD
|
||||
gh pr create --base master --label codex \
|
||||
--title "<concise title>" \
|
||||
--body "<summary of the change and testing, including 'Closes #<issue>'>"
|
||||
gh pr merge --squash --auto --delete-branch
|
||||
```
|
||||
|
||||
The branch should start with `codex/` and the PR should carry the `codex`
|
||||
label. Auto-merge waits for the required status checks (build, tests,
|
||||
golangci-lint) — never merge a PR manually before CI is green.
|
||||
|
||||
Do not just say that a PR was opened, and do **not** rely on the `make_pr` tool:
|
||||
in this environment `make_pr` only records the title/body and never pushes a
|
||||
branch or creates a PR. The task is not complete until `gh pr create` has opened
|
||||
a real pull request and printed its URL.
|
||||
+1
-1
@@ -19,7 +19,7 @@ else is additive. See the [v5 → v6 migration guide](internal/website/docs/guid
|
||||
- **JWT auth ported in-module.** The external `github.com/micro/plugins/v5/auth/jwt` (pinned to v5) is replaced by `go-micro.dev/v6/auth/jwt/token`, now on the maintained `golang-jwt/jwt/v5`; the deprecated `dgrijalva/jwt-go` dependency is dropped.
|
||||
|
||||
### Added
|
||||
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. v1 is the synchronous JSON-RPC binding (`message/send`, `tasks/get`, card discovery); streaming and push notifications are advertised as unsupported. (`gateway/a2a/`, `cmd/micro/a2a/`)
|
||||
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, and card discovery. `input-required` and `tasks/resubscribe` remain unsupported. (`gateway/a2a/`, `cmd/micro/a2a/`)
|
||||
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
|
||||
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
|
||||
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
|
||||
|
||||
@@ -47,7 +47,7 @@ test-coverage:
|
||||
harness:
|
||||
go run ./internal/harness/universe
|
||||
go run ./internal/harness/agent-flow
|
||||
go run ./internal/harness/plan-delegate
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Go Micro is an **agent harness** and service framework for Go.
|
||||
|
||||
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 that 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.
|
||||
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.
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -235,7 +237,7 @@ Just as a service composes pluggable abstractions (registry, broker, store), an
|
||||
```go
|
||||
agent := micro.NewAgent("assistant",
|
||||
micro.AgentProvider("anthropic"), // model — swap the provider
|
||||
micro.AgentMemory(micro.NewInMemory(50)), // memory — default is store-backed & durable
|
||||
micro.AgentCompactMemory(40, 12), // memory — durable, summarized, recallable
|
||||
micro.AgentTool("weather", "Get the weather for a city",
|
||||
map[string]any{"city": map[string]any{"type": "string"}},
|
||||
func(ctx context.Context, in map[string]any) (string, error) {
|
||||
@@ -245,7 +247,7 @@ agent := micro.NewAgent("assistant",
|
||||
)
|
||||
```
|
||||
|
||||
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. **Tools** are your services automatically, plus any function you register with `AgentTool`.
|
||||
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. Long-running agents can opt into `AgentCompactMemory(maxMessages, keepRecent)`: older turns are collapsed into a deterministic summary, recent turns stay verbatim, and relevant archived turns are recalled on future asks without replaying the whole conversation. **Tools** are your services automatically, plus any function you register with `AgentTool`.
|
||||
|
||||
### Paid tools (x402)
|
||||
|
||||
@@ -253,9 +255,9 @@ Every endpoint is an AI-callable tool — and it can be a *paid* tool. Go Micro
|
||||
|
||||
```bash
|
||||
# Charge for tool calls at the MCP gateway (off unless you set a pay-to address)
|
||||
micro mcp serve --x402-pay-to 0xYourAddress --x402-network solana --x402-amount 10000
|
||||
micro mcp serve --x402_pay_to 0xYourAddress --x402_network solana --x402_amount 10000
|
||||
# Per-tool amounts via a config file
|
||||
micro mcp serve --x402-config x402.json
|
||||
micro mcp serve --x402_config x402.json
|
||||
```
|
||||
|
||||
See the [Payments (x402) guide](internal/website/docs/guides/x402-payments.md).
|
||||
|
||||
+5
-4
@@ -15,7 +15,8 @@ The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micr
|
||||
## Where we are (v6)
|
||||
|
||||
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
|
||||
flows, the MCP and A2A gateways (both directions), x402 paid tools, secure by
|
||||
flows, the MCP and A2A gateways (both directions, including A2A streaming,
|
||||
push notifications, and multi-turn continuation), x402 paid tools, secure by
|
||||
default.
|
||||
|
||||
## Principles
|
||||
@@ -41,14 +42,14 @@ default.
|
||||
## Next — agentic depth
|
||||
|
||||
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
|
||||
- **Streaming** — `ai.Stream` + A2A `message/stream`, end to end.
|
||||
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
|
||||
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
|
||||
|
||||
## Later
|
||||
|
||||
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
|
||||
x402 live-facilitator conformance and paid remote tools with spend caps; A2A
|
||||
streaming, push notifications, and multi-turn tasks.
|
||||
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
|
||||
handoffs.
|
||||
|
||||
## Developer experience (ongoing)
|
||||
|
||||
|
||||
+116
-10
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/server"
|
||||
"go-micro.dev/v6/store"
|
||||
@@ -42,6 +43,7 @@ type Agent interface {
|
||||
Init(...Option)
|
||||
Options() Options
|
||||
Ask(ctx context.Context, message string) (*Response, error)
|
||||
Stream(ctx context.Context, message string) (ai.Stream, error)
|
||||
Run() error
|
||||
Stop() error
|
||||
String() string
|
||||
@@ -85,6 +87,11 @@ type agentImpl struct {
|
||||
// Both are surfaced to tool wrappers via ai.RunInfo on the context.
|
||||
runID string
|
||||
parentRunID string
|
||||
|
||||
// pause records a guardrail approval pause raised during the current
|
||||
// Ask. The model provider only sees a refused tool result; the agent
|
||||
// converts it into a durable paused run instead of completing the run.
|
||||
pause *approvalPause
|
||||
}
|
||||
|
||||
// New creates a new Agent.
|
||||
@@ -136,7 +143,7 @@ func (a *agentImpl) setup() {
|
||||
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.opts.TraceProvider != nil && a.model != nil {
|
||||
if a.model != nil {
|
||||
a.model = a.tracedModel(a.model)
|
||||
}
|
||||
|
||||
@@ -148,6 +155,8 @@ func (a *agentImpl) setup() {
|
||||
a.mem = a.opts.Memory
|
||||
case a.ephemeral:
|
||||
a.mem = NewInMemory(a.opts.HistoryLimit)
|
||||
case a.opts.MemoryCompaction.MaxMessages > 0:
|
||||
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
|
||||
default:
|
||||
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
|
||||
}
|
||||
@@ -168,6 +177,53 @@ func (a *agentImpl) stateStore() store.Store {
|
||||
// Ask sends a message and returns the agent's response.
|
||||
// This is the programmatic API for direct use.
|
||||
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
|
||||
return a.ask(ctx, message, a.parentRunID)
|
||||
}
|
||||
|
||||
// Stream sends a message and returns a streaming model response. Tool-calling
|
||||
// agent runs still use Ask; Stream is for chat turns where immediate token
|
||||
// delivery is more important than tool orchestration.
|
||||
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
toolList, err := a.discoverTools()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
return a.model.Stream(ctx, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
})
|
||||
}
|
||||
|
||||
// Resume returns the response for a checkpointed agent run. Completed runs are
|
||||
// returned from the checkpoint without calling the model or replaying tool
|
||||
// calls; failed or in-progress runs continue from the saved input message.
|
||||
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resume(ctx, runID)
|
||||
}
|
||||
|
||||
// Pending returns checkpointed agent runs that have not completed. It mirrors
|
||||
// flow.Pending for startup recovery loops that drain durable agent work.
|
||||
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.pending(ctx)
|
||||
}
|
||||
|
||||
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -175,6 +231,10 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
|
||||
a.setup()
|
||||
}
|
||||
|
||||
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -183,30 +243,61 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
|
||||
a.mem.Add("user", message)
|
||||
a.steps = 0
|
||||
a.calls = map[string]int{}
|
||||
a.pause = nil
|
||||
|
||||
// Correlate this run's tool calls and surface lineage to wrappers.
|
||||
a.runID = uuid.New().String()
|
||||
a.runID = runID
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: a.parentRunID,
|
||||
ParentID: parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
run := a.newCheckpointRun(runID, message, parentRunID, existing)
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
defer func() { endRun(err) }()
|
||||
|
||||
messages := a.mem.Messages()
|
||||
if recall, ok := a.mem.(MemoryRecall); ok && a.opts.MemoryRecallLimit > 0 {
|
||||
if recalled := recall.Recall(message, a.opts.MemoryRecallLimit); len(recalled) > 0 {
|
||||
messages = append([]ai.Message{{
|
||||
Role: "system",
|
||||
Content: "Relevant recalled memory follows; use it as durable prior context without assuming the whole conversation was replayed.",
|
||||
}}, append(recalled, messages...)...)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := ai.GenerateWithRetry(ctx, a.model, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
Messages: messages,
|
||||
}, ai.GeneratePolicy{
|
||||
Timeout: a.opts.ModelTimeout,
|
||||
MaxAttempts: a.opts.ModelMaxAttempts,
|
||||
Backoff: a.opts.ModelRetryBackoff,
|
||||
})
|
||||
if err != nil {
|
||||
run.Status = "failed"
|
||||
run.Steps[0].Status = "failed"
|
||||
run.Steps[0].Error = err.Error()
|
||||
_ = a.saveRun(ctx, run)
|
||||
return nil, err
|
||||
}
|
||||
if a.pause != nil && a.opts.Checkpoint != nil {
|
||||
run.Status = "paused"
|
||||
run.State.Stage = agentApprovalStep
|
||||
run.State.Data = []byte(message)
|
||||
run.Steps[0].Status = "paused"
|
||||
run.Steps[0].Error = a.pause.Message
|
||||
run.Steps[0].Result = a.pause.Tool
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
a.mem.Add("assistant", resp.Reply)
|
||||
@@ -223,24 +314,38 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
|
||||
reply += resp.Answer
|
||||
}
|
||||
|
||||
return &Response{
|
||||
res := &Response{
|
||||
Reply: reply,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: a.parentRunID,
|
||||
}, nil
|
||||
ParentID: parentRunID,
|
||||
}
|
||||
run.Status = "done"
|
||||
run.State.Stage = ""
|
||||
if b, marshalErr := json.Marshal(res); marshalErr == nil {
|
||||
run.State.Data = b
|
||||
}
|
||||
run.Steps[0].Status = "done"
|
||||
run.Steps[0].Attempts++
|
||||
run.Steps[0].Result = reply
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Chat implements the proto AgentHandler interface for RPC.
|
||||
// @example {"message": "What tasks are overdue?"}
|
||||
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
|
||||
resp, err := a.Ask(ctx, req.Message)
|
||||
resp, err := a.ask(ctx, req.Message, req.ParentId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.Reply = resp.Reply
|
||||
rsp.Agent = resp.Agent
|
||||
rsp.RunId = resp.RunID
|
||||
rsp.ParentId = resp.ParentID
|
||||
for _, tc := range resp.ToolCalls {
|
||||
input, _ := json.Marshal(tc.Input)
|
||||
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
|
||||
@@ -261,6 +366,7 @@ func (a *agentImpl) Run() error {
|
||||
|
||||
a.server = server.NewServer(
|
||||
server.Name(a.opts.Name),
|
||||
server.Address(a.opts.Address),
|
||||
server.Registry(a.opts.Registry),
|
||||
server.Metadata(map[string]string{
|
||||
"type": "agent",
|
||||
@@ -280,13 +386,13 @@ func (a *agentImpl) Run() error {
|
||||
// Ask in-process — no separate gateway needed to be queried by URL.
|
||||
if a.opts.A2AAddress != "" {
|
||||
card := a2a.Card(a.opts.Name, "http://localhost"+a.opts.A2AAddress, "", a.opts.Services)
|
||||
handler := a2a.NewAgentHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
resp, err := a.Ask(ctx, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
})
|
||||
}, 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)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -34,6 +38,51 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponseIncludesRunIDs(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("chat-run"))
|
||||
var rsp pb.ChatResponse
|
||||
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello"}, &rsp); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if rsp.RunId == "" {
|
||||
t.Fatal("Chat response RunId is empty")
|
||||
}
|
||||
if rsp.Agent != "chat-run" {
|
||||
t.Errorf("Agent = %q, want chat-run", rsp.Agent)
|
||||
}
|
||||
if rsp.ParentId != "" {
|
||||
t.Errorf("ParentId = %q, want empty", rsp.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRequestParentIDPropagatesToResponse(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from model context")
|
||||
}
|
||||
if info.ParentID != "flow-run-123" {
|
||||
t.Fatalf("RunInfo.ParentID = %q, want flow-run-123", info.ParentID)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("chat-child"))
|
||||
var rsp pb.ChatResponse
|
||||
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello", ParentId: "flow-run-123"}, &rsp); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if rsp.ParentId != "flow-run-123" {
|
||||
t.Errorf("ParentId = %q, want flow-run-123", rsp.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
// Custom prompt
|
||||
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
|
||||
|
||||
@@ -108,6 +108,7 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
h = a.loopWrap(h)
|
||||
h = a.stepWrap(h)
|
||||
h = a.planWrap(h)
|
||||
h = contextWrap(h)
|
||||
h = a.traceTool(h)
|
||||
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
|
||||
h = a.opts.wrappers[i](h)
|
||||
@@ -115,6 +116,21 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// contextWrap stops tool execution promptly when the Ask context has
|
||||
// already been canceled or its deadline has expired. This keeps guardrail
|
||||
// bookkeeping and side-effecting tools from running after the caller has
|
||||
// abandoned the agent run.
|
||||
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errResult(call.ID, ctx.Err().Error())
|
||||
default:
|
||||
}
|
||||
return next(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -184,6 +200,11 @@ func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
}
|
||||
|
||||
// approveWrap gates each action before it runs (ApproveTool).
|
||||
type approvalPause struct {
|
||||
Tool string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Approve != nil {
|
||||
@@ -192,6 +213,7 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if reason != "" {
|
||||
msg += ": " + reason
|
||||
}
|
||||
a.pause = &approvalPause{Tool: call.Name, Message: msg}
|
||||
return refused(call.ID, ai.RefusedApproval, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
const (
|
||||
agentAskStep = "ask"
|
||||
agentApprovalStep = "approval"
|
||||
)
|
||||
|
||||
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
|
||||
now := time.Now()
|
||||
run := flow.Run{
|
||||
ID: runID,
|
||||
ParentID: parentRunID,
|
||||
Flow: a.opts.Name,
|
||||
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
|
||||
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
|
||||
Status: "running",
|
||||
Started: now,
|
||||
Updated: now,
|
||||
}
|
||||
if existing != nil {
|
||||
run = *existing
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
||||
}
|
||||
run.Steps[0].Status = "in_progress"
|
||||
run.Steps[0].Error = ""
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil
|
||||
}
|
||||
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
|
||||
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
}
|
||||
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent run %s not found", runID)
|
||||
}
|
||||
if run.Status == "paused" {
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
}
|
||||
if run.Status == "done" {
|
||||
var resp Response
|
||||
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
message := string(run.State.Data)
|
||||
parentID := run.ParentID
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, message, parentID, &run)
|
||||
}
|
||||
|
||||
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, nil
|
||||
}
|
||||
runs, err := a.opts.Checkpoint.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := runs[:0]
|
||||
for _, run := range runs {
|
||||
if run.Flow == a.opts.Name && run.Status != "done" {
|
||||
out = append(out, run)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
return &ai.Response{Reply: "done"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("durable-agent"), WithCheckpoint(cp))
|
||||
resp, err := a.Ask(ctx, "finish the work")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("model calls after Ask = %d, want 1", calls)
|
||||
}
|
||||
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
t.Fatal("Resume of a completed run replayed the model")
|
||||
return nil, nil
|
||||
}
|
||||
resumed, err := Resume(ctx, a, resp.RunID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resumed.Reply != "done" {
|
||||
t.Fatalf("resumed reply = %q, want done", resumed.Reply)
|
||||
}
|
||||
if resumed.RunID != resp.RunID {
|
||||
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("model calls after Resume = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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)
|
||||
}
|
||||
a := newTestAgent(Name("pending-agent"), WithCheckpoint(cp))
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ID != "run-1" {
|
||||
t.Fatalf("Pending = %#v, want run-1", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
if opts.ToolHandler != nil {
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.approve", Input: map[string]any{"id": "42"}})
|
||||
}
|
||||
return &ai.Response{Reply: "model saw approval result"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
approved := false
|
||||
a := newTestAgent(Name("approval-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.approve", "guarded external action", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
return approved, "waiting for operator"
|
||||
}))
|
||||
_, err := a.Ask(ctx, "send the guarded update")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want paused approval error")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if runs[0].Status != "paused" || runs[0].State.Stage != agentApprovalStep {
|
||||
t.Fatalf("run status/stage = %s/%s, want paused/%s", runs[0].Status, runs[0].State.Stage, agentApprovalStep)
|
||||
}
|
||||
if got := string(runs[0].State.Data); got != "send the guarded update" {
|
||||
t.Fatalf("paused run data = %q", got)
|
||||
}
|
||||
|
||||
approved = true
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
if opts.ToolHandler != nil {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-2", Name: "external.approve", Input: map[string]any{"id": "42"}})
|
||||
if res.Refused != "" {
|
||||
t.Fatalf("resumed call was refused: %#v", res)
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "done after approval"}, nil
|
||||
}
|
||||
resp, err := Resume(ctx, a, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resp.Reply != "done after approval" {
|
||||
t.Fatalf("Resume reply = %q", resp.Reply)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" {
|
||||
t.Fatalf("resumed run status = %q, want done", loaded.Status)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("model calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type conformanceProvider struct {
|
||||
name string
|
||||
model string
|
||||
key string
|
||||
live bool
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceMatrix(t *testing.T) {
|
||||
providers := []conformanceProvider{
|
||||
{name: "fake"},
|
||||
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
|
||||
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
|
||||
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
|
||||
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
|
||||
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
|
||||
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
|
||||
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
provider := provider
|
||||
t.Run(provider.name, func(t *testing.T) {
|
||||
runAgentConformanceScenario(t, provider)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
t.Helper()
|
||||
if provider.live {
|
||||
if os.Getenv(provider.key) == "" {
|
||||
t.Skipf("%s not set; skipping live %s conformance", provider.key, provider.name)
|
||||
}
|
||||
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
|
||||
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s conformance", provider.name)
|
||||
}
|
||||
} else {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if req.Prompt == "" {
|
||||
return nil, errors.New("missing prompt")
|
||||
}
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
return nil, errors.New("missing tools")
|
||||
}
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("empty tool result")
|
||||
}
|
||||
return &ai.Response{
|
||||
Reply: "used conformance_echo",
|
||||
Answer: res.Content,
|
||||
ToolCalls: []ai.ToolCall{{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: res.Content}},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
}
|
||||
|
||||
var sawTool bool
|
||||
var sawRunInfo bool
|
||||
agentOpts := []Option{
|
||||
Name("conformance-" + provider.name),
|
||||
Provider(provider.name),
|
||||
APIKey(os.Getenv(provider.key)),
|
||||
Prompt("You are a conformance test agent. Use the conformance_echo tool exactly once with input {\"value\":\"agent-conformance\"}, then answer with the tool result."),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(8)),
|
||||
ModelCallTimeout(45 * time.Second),
|
||||
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
|
||||
"value": map[string]any{"type": "string", "description": "value to echo"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
return "", errors.New("missing run info")
|
||||
}
|
||||
if info.RunID == "" || info.Agent != "conformance-"+provider.name {
|
||||
return "", fmt.Errorf("unexpected run info: %+v", info)
|
||||
}
|
||||
sawRunInfo = true
|
||||
if input["value"] != "agent-conformance" {
|
||||
return "", fmt.Errorf("unexpected value %v", input["value"])
|
||||
}
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
}
|
||||
if provider.model != "" {
|
||||
if model := os.Getenv(provider.model); model != "" {
|
||||
agentOpts = append(agentOpts, Model(model))
|
||||
}
|
||||
}
|
||||
|
||||
a := New(agentOpts...)
|
||||
resp, err := a.Ask(context.Background(), "Run the provider conformance check.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.RunID == "" {
|
||||
t.Fatal("RunID is empty")
|
||||
}
|
||||
if resp.Agent != "conformance-"+provider.name {
|
||||
t.Fatalf("Agent = %q", resp.Agent)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("provider did not request the conformance tool")
|
||||
}
|
||||
if !sawRunInfo {
|
||||
t.Fatal("tool did not receive RunInfo")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
|
||||
t.Fatalf("reply %q does not include conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, errors.New("conformance provider failure")
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := New(
|
||||
Name("conformance-error"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
)
|
||||
_, err := a.Ask(context.Background(), "fail deterministically")
|
||||
if err == nil || !strings.Contains(err.Error(), "conformance provider failure") {
|
||||
t.Fatalf("Ask error = %v, want conformance provider failure", err)
|
||||
}
|
||||
}
|
||||
@@ -179,3 +179,46 @@ func TestDelegateRequiresTask(t *testing.T) {
|
||||
t.Errorf("delegate with no task should error; got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
|
||||
var sawSummary, sawRecall bool
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
for _, msg := range req.Messages {
|
||||
text := msg.Content.(string)
|
||||
if strings.Contains(text, "Conversation memory summary") && strings.Contains(text, "alpha project") {
|
||||
sawSummary = true
|
||||
}
|
||||
if strings.Contains(text, "alpha project budget is 42") {
|
||||
sawRecall = true
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("memory"), CompactMemory(4, 2), MemoryRecallLimit(3))
|
||||
turns := []string{
|
||||
"alpha project budget is 42",
|
||||
"beta project owner is sam",
|
||||
"gamma project deadline is monday",
|
||||
"delta project status is green",
|
||||
"epsilon project risk is low",
|
||||
}
|
||||
for _, turn := range turns {
|
||||
if _, err := a.Ask(context.Background(), turn); err != nil {
|
||||
t.Fatalf("Ask(%q): %v", turn, err)
|
||||
}
|
||||
}
|
||||
if got := len(a.mem.Messages()); got > 4 {
|
||||
t.Fatalf("compacted memory retained %d messages, want <= 4", got)
|
||||
}
|
||||
if _, err := a.Ask(context.Background(), "what was the alpha budget?"); err != nil {
|
||||
t.Fatalf("Ask recall: %v", err)
|
||||
}
|
||||
if !sawSummary {
|
||||
t.Error("model request did not include a deterministic compacted summary")
|
||||
}
|
||||
if !sawRecall {
|
||||
t.Error("model request did not recall archived matching context")
|
||||
}
|
||||
}
|
||||
|
||||
+159
-9
@@ -2,6 +2,8 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -21,6 +23,21 @@ type Memory interface {
|
||||
Clear()
|
||||
}
|
||||
|
||||
// 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
|
||||
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
|
||||
type MemoryCompaction struct {
|
||||
MaxMessages int
|
||||
KeepRecent int
|
||||
}
|
||||
|
||||
// MemoryRecall is implemented by memory backends that can retrieve durable
|
||||
// prior context relevant to a new turn without replaying every stored message.
|
||||
type MemoryRecall interface {
|
||||
Recall(query string, limit int) []ai.Message
|
||||
}
|
||||
|
||||
// NewMemory returns the default store-backed memory: an in-process
|
||||
// conversation buffer (truncated to limit) that persists to the store
|
||||
// under key, so an agent picks up where it left off after a restart.
|
||||
@@ -31,6 +48,33 @@ func NewMemory(s store.Store, key string, limit int) Memory {
|
||||
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 {
|
||||
if keepRecent <= 0 {
|
||||
keepRecent = maxMessages / 2
|
||||
}
|
||||
if keepRecent < 1 {
|
||||
keepRecent = 1
|
||||
}
|
||||
m := &storeMemory{
|
||||
store: s,
|
||||
key: key,
|
||||
// Use an unlimited buffer here; compaction, not truncation, decides
|
||||
// what remains in active context so a summary can preserve older turns.
|
||||
hist: ai.NewHistory(0),
|
||||
compaction: MemoryCompaction{
|
||||
MaxMessages: maxMessages,
|
||||
KeepRecent: keepRecent,
|
||||
},
|
||||
}
|
||||
m.load()
|
||||
m.compact()
|
||||
return m
|
||||
}
|
||||
|
||||
// NewInMemory returns conversation memory that is not persisted.
|
||||
func NewInMemory(limit int) Memory {
|
||||
return &storeMemory{hist: ai.NewHistory(limit)}
|
||||
@@ -39,16 +83,19 @@ 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
|
||||
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()
|
||||
m.hist.Add(role, content)
|
||||
m.mu.Unlock()
|
||||
m.compact()
|
||||
m.save()
|
||||
}
|
||||
|
||||
@@ -61,10 +108,35 @@ func (m *storeMemory) Messages() []ai.Message {
|
||||
func (m *storeMemory) Clear() {
|
||||
m.mu.Lock()
|
||||
m.hist.Reset()
|
||||
m.archive = nil
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
terms := recallTerms(query)
|
||||
var out []ai.Message
|
||||
for i := len(m.archive) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
msg := m.archive[i]
|
||||
text := strings.ToLower(fmt.Sprint(msg.Content))
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, term) {
|
||||
out = append(out, msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *storeMemory) load() {
|
||||
if m.store == nil || m.key == "" {
|
||||
return
|
||||
@@ -73,12 +145,17 @@ func (m *storeMemory) load() {
|
||||
if err != nil || len(recs) == 0 {
|
||||
return
|
||||
}
|
||||
var msgs []ai.Message
|
||||
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
|
||||
return
|
||||
var state memoryState
|
||||
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
|
||||
var msgs []ai.Message
|
||||
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
|
||||
return
|
||||
}
|
||||
state.Messages = msgs
|
||||
}
|
||||
m.mu.Lock()
|
||||
for _, msg := range msgs {
|
||||
m.archive = state.Archive
|
||||
for _, msg := range state.Messages {
|
||||
m.hist.Add(msg.Role, msg.Content)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
@@ -89,10 +166,83 @@ func (m *storeMemory) save() {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
data, err := json.Marshal(m.hist.Messages())
|
||||
data, err := json.Marshal(memoryState{
|
||||
Messages: m.hist.Messages(),
|
||||
Archive: m.archive,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
|
||||
}
|
||||
|
||||
func (m *storeMemory) compact() {
|
||||
if m.compaction.MaxMessages <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
msgs := m.hist.Messages()
|
||||
if len(msgs) <= m.compaction.MaxMessages {
|
||||
return
|
||||
}
|
||||
keep := m.compaction.KeepRecent
|
||||
if keep <= 0 || keep >= m.compaction.MaxMessages {
|
||||
keep = m.compaction.MaxMessages - 1
|
||||
}
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
}
|
||||
cut := len(msgs) - keep
|
||||
older := msgs[:cut]
|
||||
recent := msgs[cut:]
|
||||
m.archive = append(m.archive, older...)
|
||||
summary := ai.Message{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
|
||||
}
|
||||
m.hist.Reset()
|
||||
m.hist.Add(summary.Role, summary.Content)
|
||||
for _, msg := range recent {
|
||||
m.hist.Add(msg.Role, msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeMessages(msgs []ai.Message) string {
|
||||
var b strings.Builder
|
||||
for i, msg := range msgs {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func compactText(s string, max int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if max > 0 && len(s) > max {
|
||||
return s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func recallTerms(query string) []string {
|
||||
seen := map[string]bool{}
|
||||
var terms []string
|
||||
for _, term := range strings.Fields(strings.ToLower(query)) {
|
||||
term = strings.Trim(term, ".,!?;:\"'()[]{}")
|
||||
if len(term) < 3 || seen[term] {
|
||||
continue
|
||||
}
|
||||
seen[term] = true
|
||||
terms = append(terms, term)
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
type memoryState struct {
|
||||
Messages []ai.Message `json:"messages"`
|
||||
Archive []ai.Message `json:"archive,omitempty"`
|
||||
}
|
||||
|
||||
+50
-2
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -39,6 +40,7 @@ type Options struct {
|
||||
Provider string
|
||||
Model string
|
||||
APIKey string
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Store store.Store
|
||||
@@ -58,6 +60,16 @@ type Options struct {
|
||||
// Memory is the agent's conversation memory. Nil = the default
|
||||
// store-backed memory (durable across restarts).
|
||||
Memory Memory
|
||||
// MemoryCompaction enables deterministic compaction/retrieval on the
|
||||
// default store-backed memory. Custom Memory implementations can expose
|
||||
// retrieval by implementing MemoryRecall.
|
||||
MemoryCompaction MemoryCompaction
|
||||
// MemoryRecallLimit bounds recalled archived turns injected into a model
|
||||
// request (0 disables recall injection).
|
||||
MemoryRecallLimit int
|
||||
// Checkpoint persists agent Ask runs so callers can resume by run id
|
||||
// after a restart without replaying a run that already completed.
|
||||
Checkpoint flow.Checkpoint
|
||||
|
||||
// MaxSteps bounds the number of tool executions per Ask (0 =
|
||||
// unbounded). Once exceeded, further tool calls are refused and the
|
||||
@@ -135,6 +147,13 @@ func APIKey(k string) Option {
|
||||
return func(o *Options) { o.APIKey = k }
|
||||
}
|
||||
|
||||
// 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.
|
||||
func Address(addr string) Option {
|
||||
return func(o *Options) { o.Address = addr }
|
||||
}
|
||||
|
||||
// WithRegistry sets the service registry.
|
||||
func WithRegistry(r registry.Registry) Option {
|
||||
return func(o *Options) { o.Registry = r }
|
||||
@@ -203,6 +222,34 @@ func WithMemory(m Memory) Option {
|
||||
return func(o *Options) { o.Memory = m }
|
||||
}
|
||||
|
||||
// CompactMemory enables deterministic, store-backed memory compaction for the
|
||||
// default agent memory. Older turns are summarized once active context exceeds
|
||||
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
|
||||
// turns are injected into matching future asks.
|
||||
func CompactMemory(maxMessages, keepRecent int) Option {
|
||||
return func(o *Options) {
|
||||
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent}
|
||||
if o.MemoryRecallLimit == 0 {
|
||||
o.MemoryRecallLimit = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return func(o *Options) { o.MemoryRecallLimit = n }
|
||||
}
|
||||
|
||||
// WithCheckpoint sets the durability backend for agent Ask runs. The
|
||||
// Checkpoint interface is shared with flow so services, agents, and workflows
|
||||
// can use one execution history backend. When set, each Ask is saved as a
|
||||
// single-step run keyed by run id; Resume returns a completed run's persisted
|
||||
// response instead of calling the model again.
|
||||
func WithCheckpoint(c flow.Checkpoint) Option {
|
||||
return func(o *Options) { o.Checkpoint = c }
|
||||
}
|
||||
|
||||
// WrapTool registers a tool-execution wrapper, the tool-side analog of
|
||||
// a client/server middleware wrapper. Each wrapper takes the next handler
|
||||
// and returns a new one; code before the next(...) call runs before the
|
||||
@@ -242,8 +289,9 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider enables OpenTelemetry tracing for agent runs. When nil,
|
||||
// agent tracing and run timeline recording are disabled.
|
||||
// TraceProvider enables OpenTelemetry tracing for agent runs. The persisted
|
||||
// run timeline is recorded even when TraceProvider is nil; trace/span IDs are
|
||||
// added only when a provider is configured.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
|
||||
+159
-34
@@ -41,6 +41,8 @@ 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"`
|
||||
@@ -54,16 +56,35 @@ type RunEvent struct {
|
||||
|
||||
type Usage = ai.Usage
|
||||
|
||||
// RunListOptions controls how recorded agent run summaries are returned.
|
||||
// Zero values preserve the full deterministic run list.
|
||||
type RunListOptions struct {
|
||||
// Status, when set, keeps only runs with the matching status
|
||||
// (for example "running", "done", "error", or "refused").
|
||||
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
|
||||
// printed by `micro runs`.
|
||||
TraceID string
|
||||
// Limit, when positive, returns the most recently updated runs up to
|
||||
// the limit. Limited results are ordered newest first.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// 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"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Events int `json:"events"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
RunID string `json:"run_id"`
|
||||
Agent string `json:"agent"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
Events int `json:"events"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) tracer() trace.Tracer {
|
||||
@@ -71,22 +92,34 @@ func (a *agentImpl) tracer() trace.Tracer {
|
||||
}
|
||||
|
||||
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, func(error)) {
|
||||
if a.opts.TraceProvider == nil {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
start := time.Now()
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
|
||||
}
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
|
||||
start := time.Now()
|
||||
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
return ctx, func(err error) {
|
||||
span.SetAttributes(attribute.Int64(AttrLatencyMS, time.Since(start).Milliseconds()))
|
||||
latency := time.Since(start).Milliseconds()
|
||||
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", 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()})
|
||||
} 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})
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
@@ -99,14 +132,33 @@ type tracedModel struct {
|
||||
|
||||
func (a *agentImpl) tracedModel(m ai.Model) ai.Model { return &tracedModel{Model: m, a: a} }
|
||||
func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.a.opts.TraceProvider == nil {
|
||||
return m.Model.Generate(ctx, req, opts...)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
provider := m.String()
|
||||
model := m.Options().Model
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attribute.String(AttrProvider, provider), attribute.String(AttrModel, model)))
|
||||
start := time.Now()
|
||||
|
||||
if m.a.opts.TraceProvider == nil {
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
usage := ai.Usage{}
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
}
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
m.a.recordRunEvent(e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
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),
|
||||
))
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
@@ -127,7 +179,7 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
m.a.recordRunEvent(e)
|
||||
m.a.recordSpanEvent(span, e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -145,13 +197,24 @@ func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
|
||||
}
|
||||
|
||||
func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if a.opts.TraceProvider == nil {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(attribute.String(AttrToolName, call.Name), attribute.Bool(AttrDelegate, call.Name == toolDelegate)))
|
||||
start := time.Now()
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resultError(res)})
|
||||
return res
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
attribute.String(AttrToolName, call.Name),
|
||||
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
|
||||
))
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
@@ -168,7 +231,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
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})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -185,8 +248,16 @@ func resultError(res ai.ToolResult) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
|
||||
if sc := span.SpanContext(); sc.IsValid() {
|
||||
e.TraceID = sc.TraceID().String()
|
||||
e.SpanID = sc.SpanID().String()
|
||||
}
|
||||
a.recordRunEvent(e)
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if a.opts.TraceProvider == nil || e.RunID == "" {
|
||||
if e.RunID == "" {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(e)
|
||||
@@ -196,6 +267,12 @@ func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
|
||||
// ListRunSummaries returns a deterministic summary of recorded runs for agentName.
|
||||
func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
|
||||
return ListRunSummariesWithOptions(s, agentName, RunListOptions{})
|
||||
}
|
||||
|
||||
// ListRunSummariesWithOptions returns summaries of recorded runs for agentName,
|
||||
// optionally filtered by status and limited to the most recently updated runs.
|
||||
func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOptions) ([]RunSummary, error) {
|
||||
st := store.Scope(s, "agent", agentName)
|
||||
keys, err := st.List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
@@ -226,14 +303,18 @@ func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
|
||||
first := events[0]
|
||||
last := events[len(events)-1]
|
||||
summary := RunSummary{
|
||||
RunID: id,
|
||||
Agent: first.Agent,
|
||||
ParentID: first.ParentID,
|
||||
StartedAt: first.Time,
|
||||
UpdatedAt: last.Time,
|
||||
Events: len(events),
|
||||
LastKind: last.Kind,
|
||||
LastError: last.Error,
|
||||
RunID: id,
|
||||
Agent: first.Agent,
|
||||
ParentID: first.ParentID,
|
||||
TraceID: first.TraceID,
|
||||
SpanID: first.SpanID,
|
||||
StartedAt: first.Time,
|
||||
UpdatedAt: last.Time,
|
||||
DurationMS: last.Time.Sub(first.Time).Milliseconds(),
|
||||
Events: len(events),
|
||||
Status: runStatus(events),
|
||||
LastKind: last.Kind,
|
||||
LastError: last.Error,
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Agent != "" {
|
||||
@@ -242,15 +323,59 @@ func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
|
||||
if e.ParentID != "" {
|
||||
summary.ParentID = e.ParentID
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
summary.TraceID = e.TraceID
|
||||
}
|
||||
if e.SpanID != "" {
|
||||
summary.SpanID = e.SpanID
|
||||
}
|
||||
if e.Error != "" {
|
||||
summary.LastError = e.Error
|
||||
}
|
||||
}
|
||||
if opts.Status != "" && summary.Status != opts.Status {
|
||||
continue
|
||||
}
|
||||
if opts.TraceID != "" && !strings.HasPrefix(summary.TraceID, opts.TraceID) {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
|
||||
})
|
||||
if len(summaries) > opts.Limit {
|
||||
summaries = summaries[:opts.Limit]
|
||||
}
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func runStatus(events []RunEvent) string {
|
||||
if len(events) == 0 {
|
||||
return ""
|
||||
}
|
||||
status := "running"
|
||||
for _, e := range events {
|
||||
if e.Error != "" {
|
||||
status = "error"
|
||||
}
|
||||
if e.Refused != "" && status != "error" {
|
||||
status = "refused"
|
||||
}
|
||||
switch e.Kind {
|
||||
case "error":
|
||||
status = "error"
|
||||
case "done":
|
||||
if status == "running" {
|
||||
status = "done"
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
|
||||
st := store.Scope(s, "agent", agentName)
|
||||
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
|
||||
|
||||
+172
-9
@@ -3,11 +3,14 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
@@ -27,7 +30,11 @@ func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOptio
|
||||
}
|
||||
func (m *otelTestModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
|
||||
if strings.Contains(req.Prompt, "delegate") {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-delegate", Name: toolDelegate, Input: map[string]any{"task": "subtask"}})
|
||||
} else if !strings.Contains(req.Prompt, "subtask") {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "done", Usage: ai.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}}, nil
|
||||
}
|
||||
@@ -46,16 +53,33 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
}
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
want := map[string]bool{spanNameRun: false, spanNameModelCall: false, spanNameToolCall: false}
|
||||
var runID string
|
||||
for _, s := range spans {
|
||||
if _, ok := want[s.Name()]; ok {
|
||||
want[s.Name()] = true
|
||||
}
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun {
|
||||
runID = attrs[AttrRunID]
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
|
||||
}
|
||||
}
|
||||
if runID == "" {
|
||||
t.Fatal("run span missing run id attribute")
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
|
||||
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -63,9 +87,94 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected run events to be recorded")
|
||||
}
|
||||
summaries, err := ListRunSummaries(st, "runner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("got %d summaries, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].LastKind != "done" {
|
||||
t.Fatalf("LastKind = %q, want done", summaries[0].LastKind)
|
||||
}
|
||||
if summaries[0].Status != "done" {
|
||||
t.Fatalf("Status = %q, want done", summaries[0].Status)
|
||||
}
|
||||
if summaries[0].DurationMS < 0 {
|
||||
t.Fatalf("DurationMS = %d, want non-negative", summaries[0].DurationMS)
|
||||
}
|
||||
if summaries[0].TraceID == "" || summaries[0].SpanID == "" {
|
||||
t.Fatalf("summary missing trace correlation: %#v", summaries[0])
|
||||
}
|
||||
events, err := LoadRunEvents(st, "runner", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) == 0 || events[0].TraceID == "" || events[0].SpanID == "" {
|
||||
t.Fatalf("events missing trace correlation: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetryNoopWhenUnconfigured(t *testing.T) {
|
||||
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
out[string(attr.Key)] = fmt.Sprint(attr.Value.AsInterface())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("conductor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
|
||||
if _, err := a.Ask(context.Background(), "delegate please"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
var parentRunID string
|
||||
var delegateSpanID string
|
||||
var subRunSeen bool
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor" {
|
||||
parentRunID = attrs[AttrRunID]
|
||||
}
|
||||
}
|
||||
if parentRunID == "" {
|
||||
t.Fatal("parent run span missing run id")
|
||||
}
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameToolCall && attrs[AttrToolName] == toolDelegate {
|
||||
if attrs[AttrDelegate] != "true" || attrs[AttrRunID] != parentRunID {
|
||||
t.Fatalf("delegate span missing correlation attributes: %#v", attrs)
|
||||
}
|
||||
delegateSpanID = s.SpanContext().SpanID().String()
|
||||
}
|
||||
}
|
||||
if delegateSpanID == "" {
|
||||
t.Fatal("delegate tool span not emitted")
|
||||
}
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor.sub" {
|
||||
if attrs[AttrParentRunID] != parentRunID {
|
||||
t.Fatalf("sub-agent run parent attr = %q, want %q", attrs[AttrParentRunID], parentRunID)
|
||||
}
|
||||
if s.Parent().SpanID().String() != delegateSpanID {
|
||||
t.Fatalf("sub-agent run parent span = %s, want delegate span %s", s.Parent().SpanID(), delegateSpanID)
|
||||
}
|
||||
subRunSeen = true
|
||||
}
|
||||
}
|
||||
if !subRunSeen {
|
||||
t.Fatalf("sub-agent run span not emitted; got %d spans", len(spans))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
@@ -75,11 +184,37 @@ func TestAgentOpenTelemetryNoopWhenUnconfigured(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("expected no run timeline without TraceProvider, got %v", keys)
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected run timeline without TraceProvider")
|
||||
}
|
||||
if _, ok := a.(*agentImpl).model.(*tracedModel); ok {
|
||||
t.Fatal("model should not be wrapped when TraceProvider is nil")
|
||||
summaries, err := ListRunSummaries(st, "runner-noop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("got %d summaries, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].Status != "done" || summaries[0].LastKind != "done" {
|
||||
t.Fatalf("unexpected summary without TraceProvider: %#v", summaries[0])
|
||||
}
|
||||
if summaries[0].TraceID != "" || summaries[0].SpanID != "" {
|
||||
t.Fatalf("unexpected trace correlation without TraceProvider: %#v", summaries[0])
|
||||
}
|
||||
events, err := LoadRunEvents(st, "runner-noop", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{"run": false, "model": false, "tool": false, "done": false}
|
||||
for _, e := range events {
|
||||
seen[e.Kind] = true
|
||||
if e.TraceID != "" || e.SpanID != "" {
|
||||
t.Fatalf("event has trace correlation without TraceProvider: %#v", e)
|
||||
}
|
||||
}
|
||||
for kind, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("missing %s event in timeline: %#v", kind, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +256,7 @@ func TestListRunSummaries(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
events := []RunEvent{
|
||||
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", Kind: "run", Name: "first"},
|
||||
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
|
||||
@@ -144,10 +279,38 @@ func TestListRunSummaries(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d summaries, want 2: %#v", len(got), got)
|
||||
}
|
||||
if got[0].RunID != "run-a" || got[0].Events != 2 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
|
||||
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].LastKind != "error" || got[1].LastError != "boom" {
|
||||
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 TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
events := []RunEvent{
|
||||
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scoped.Write(&store.Record{Key: "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind, Value: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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 != "error" {
|
||||
t.Fatalf("filtered summaries = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-40
@@ -2,7 +2,7 @@
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.21.12
|
||||
// source: proto/agent.proto
|
||||
// source: agent/proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
@@ -22,15 +22,17 @@ const (
|
||||
)
|
||||
|
||||
type ChatRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
// parent_id correlates this chat with the workflow or agent run that dispatched it.
|
||||
ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatRequest) Reset() {
|
||||
*x = ChatRequest{}
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -42,7 +44,7 @@ func (x *ChatRequest) String() string {
|
||||
func (*ChatRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -55,7 +57,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ChatRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetMessage() string {
|
||||
@@ -65,18 +67,29 @@ func (x *ChatRequest) GetMessage() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetParentId() string {
|
||||
if x != nil {
|
||||
return x.ParentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
|
||||
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
|
||||
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
// run_id correlates this chat response with tool calls, traces, and run history.
|
||||
RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
|
||||
// parent_id is set when this response belongs to a delegated sub-agent run.
|
||||
ParentId string `protobuf:"bytes,5,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatResponse) Reset() {
|
||||
*x = ChatResponse{}
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -88,7 +101,7 @@ func (x *ChatResponse) String() string {
|
||||
func (*ChatResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -101,7 +114,7 @@ func (x *ChatResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ChatResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{1}
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetReply() string {
|
||||
@@ -125,6 +138,20 @@ func (x *ChatResponse) GetToolCalls() []*ToolCall {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetRunId() string {
|
||||
if x != nil {
|
||||
return x.RunId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetParentId() string {
|
||||
if x != nil {
|
||||
return x.ParentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -137,7 +164,7 @@ type ToolCall struct {
|
||||
|
||||
func (x *ToolCall) Reset() {
|
||||
*x = ToolCall{}
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -149,7 +176,7 @@ func (x *ToolCall) String() string {
|
||||
func (*ToolCall) ProtoMessage() {}
|
||||
|
||||
func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -162,7 +189,7 @@ func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
|
||||
func (*ToolCall) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{2}
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetId() string {
|
||||
@@ -193,18 +220,21 @@ func (x *ToolCall) GetResult() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_proto_agent_proto protoreflect.FileDescriptor
|
||||
var File_agent_proto_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_agent_proto_rawDesc = "" +
|
||||
const file_agent_proto_agent_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11proto/agent.proto\x12\x05agent\"'\n" +
|
||||
"\x17agent/proto/agent.proto\x12\x05agent\"D\n" +
|
||||
"\vChatRequest\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"j\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" +
|
||||
"\tparent_id\x18\x02 \x01(\tR\bparentId\"\x9e\x01\n" +
|
||||
"\fChatResponse\x12\x14\n" +
|
||||
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
|
||||
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
|
||||
"\n" +
|
||||
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\"\\\n" +
|
||||
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\x12\x15\n" +
|
||||
"\x06run_id\x18\x04 \x01(\tR\x05runId\x12\x1b\n" +
|
||||
"\tparent_id\x18\x05 \x01(\tR\bparentId\"\\\n" +
|
||||
"\bToolCall\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
|
||||
@@ -214,24 +244,24 @@ const file_proto_agent_proto_rawDesc = "" +
|
||||
"\x04Chat\x12\x12.agent.ChatRequest\x1a\x13.agent.ChatResponse\"\x00B\x0fZ\r./proto;agentb\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_proto_agent_proto_rawDescData []byte
|
||||
file_agent_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_agent_proto_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)))
|
||||
func file_agent_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_agent_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_agent_proto_rawDescData
|
||||
return file_agent_proto_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_proto_agent_proto_goTypes = []any{
|
||||
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_agent_proto_agent_proto_goTypes = []any{
|
||||
(*ChatRequest)(nil), // 0: agent.ChatRequest
|
||||
(*ChatResponse)(nil), // 1: agent.ChatResponse
|
||||
(*ToolCall)(nil), // 2: agent.ToolCall
|
||||
}
|
||||
var file_proto_agent_proto_depIdxs = []int32{
|
||||
var file_agent_proto_agent_proto_depIdxs = []int32{
|
||||
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
|
||||
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
|
||||
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
|
||||
@@ -242,26 +272,26 @@ var file_proto_agent_proto_depIdxs = []int32{
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_agent_proto_init() }
|
||||
func file_proto_agent_proto_init() {
|
||||
if File_proto_agent_proto != nil {
|
||||
func init() { file_agent_proto_agent_proto_init() }
|
||||
func file_agent_proto_agent_proto_init() {
|
||||
if File_agent_proto_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_proto_agent_proto_msgTypes,
|
||||
GoTypes: file_agent_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_agent_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_agent_proto_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_agent_proto = out.File
|
||||
file_proto_agent_proto_goTypes = nil
|
||||
file_proto_agent_proto_depIdxs = nil
|
||||
File_agent_proto_agent_proto = out.File
|
||||
file_agent_proto_agent_proto_goTypes = nil
|
||||
file_agent_proto_agent_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: proto/agent.proto
|
||||
// source: agent/proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
|
||||
@@ -11,12 +11,20 @@ service Agent {
|
||||
|
||||
message ChatRequest {
|
||||
string message = 1;
|
||||
|
||||
// parent_id correlates this chat with the workflow or agent run that dispatched it.
|
||||
string parent_id = 2;
|
||||
}
|
||||
|
||||
message ChatResponse {
|
||||
string reply = 1;
|
||||
string agent = 2;
|
||||
repeated ToolCall tool_calls = 3;
|
||||
|
||||
// run_id correlates this chat response with tool calls, traces, and run history.
|
||||
string run_id = 4;
|
||||
// parent_id is set when this response belongs to a delegated sub-agent run.
|
||||
string parent_id = 5;
|
||||
}
|
||||
|
||||
message ToolCall {
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -75,3 +76,29 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
|
||||
t.Fatalf("model attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{map[string]any{"task": "should not persist", "status": "pending"}},
|
||||
}})
|
||||
if !strings.Contains(res.Content, context.Canceled.Error()) {
|
||||
t.Fatalf("tool result = %q, want cancellation error", res.Content)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("cancel-tools"))
|
||||
if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if plan := a.loadPlan(); plan != "" {
|
||||
t.Fatalf("plan persisted after canceled tool context: %q", plan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,22 @@ type Response struct {
|
||||
- `ToolCalls`: List of tools the model requested (if any)
|
||||
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
|
||||
|
||||
## Provider capability matrix
|
||||
|
||||
The CLI can print the provider capabilities registered in the current build:
|
||||
|
||||
```bash
|
||||
micro ai providers
|
||||
```
|
||||
|
||||
For automation and docs generation, emit the same matrix as stable JSON:
|
||||
|
||||
```bash
|
||||
micro ai providers --json
|
||||
```
|
||||
|
||||
It reports support from Go Micro's provider registry, so the matrix reflects the model, image, and video interfaces available to this binary rather than external provider marketing claims.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
@@ -161,7 +161,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
|
||||
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
|
||||
@@ -2,6 +2,7 @@ package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -88,7 +89,7 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package atlascloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -88,8 +89,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package ai
|
||||
|
||||
import "sort"
|
||||
|
||||
// CapabilityRow is one deterministic row in a provider capability matrix.
|
||||
type CapabilityRow struct {
|
||||
// Provider is the registered provider name.
|
||||
Provider string `json:"provider"`
|
||||
Capabilities
|
||||
}
|
||||
|
||||
// Capabilities describes the AI interfaces a provider has registered.
|
||||
// It is intentionally based on package registration rather than external
|
||||
// provider marketing claims, so it reflects what this build can actually use.
|
||||
type Capabilities struct {
|
||||
// Model reports whether ai.New can construct a chat/text model provider.
|
||||
Model bool `json:"model"`
|
||||
// Image reports whether ai.NewImage can construct an image model provider.
|
||||
Image bool `json:"image"`
|
||||
// Video reports whether ai.NewVideo can construct a video model provider.
|
||||
Video bool `json:"video"`
|
||||
// Stream reports whether the provider has registered end-to-end token streaming.
|
||||
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
|
||||
// leave this false until their Stream implementation is usable.
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// ProviderCapabilities reports the capabilities registered for provider.
|
||||
func ProviderCapabilities(provider string) Capabilities {
|
||||
_, hasModel := providers[provider]
|
||||
_, hasImage := imageProviders[provider]
|
||||
_, hasVideo := videoProviders[provider]
|
||||
_, hasStream := streamProviders[provider]
|
||||
|
||||
return Capabilities{
|
||||
Model: hasModel,
|
||||
Image: hasImage,
|
||||
Video: hasVideo,
|
||||
Stream: hasStream,
|
||||
}
|
||||
}
|
||||
|
||||
// CapabilityMatrix returns a snapshot of all registered AI providers and the
|
||||
// interfaces they support. The returned map is a copy and can be modified by
|
||||
// callers without mutating the registry. Use CapabilityRows when rendering a
|
||||
// deterministic table or report.
|
||||
func CapabilityMatrix() map[string]Capabilities {
|
||||
names := map[string]struct{}{}
|
||||
for name := range providers {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range imageProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range videoProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range streamProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
matrix := make(map[string]Capabilities, len(names))
|
||||
for name := range names {
|
||||
matrix[name] = ProviderCapabilities(name)
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
// CapabilityRows returns a deterministic capability support matrix for every
|
||||
// registered AI provider. It is the ordered form of CapabilityMatrix, intended
|
||||
// for CLIs, docs generators, and conformance reports that need stable output.
|
||||
func CapabilityRows() []CapabilityRow {
|
||||
names := RegisteredProviders("")
|
||||
rows := make([]CapabilityRow, 0, len(names))
|
||||
for _, name := range names {
|
||||
rows = append(rows, CapabilityRow{
|
||||
Provider: name,
|
||||
Capabilities: ProviderCapabilities(name),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// RegisterStream records that provider has a usable Stream implementation.
|
||||
// Providers should call this from init alongside Register once Stream returns
|
||||
// chunks instead of ErrStreamingUnsupported.
|
||||
func RegisterStream(provider string) {
|
||||
streamProviders[provider] = struct{}{}
|
||||
}
|
||||
|
||||
var streamProviders = make(map[string]struct{})
|
||||
|
||||
// RegisteredProviders returns the registered provider names in sorted order.
|
||||
// kind may be "model", "image", "video", "stream", or empty for the union of all
|
||||
// provider registries.
|
||||
func RegisteredProviders(kind string) []string {
|
||||
names := map[string]struct{}{}
|
||||
add := func(registry any) {
|
||||
switch r := registry.(type) {
|
||||
case map[string]NewFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]NewImageFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]NewVideoFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]struct{}:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "model":
|
||||
add(providers)
|
||||
case "stream":
|
||||
add(streamProviders)
|
||||
case "image":
|
||||
add(imageProviders)
|
||||
case "video":
|
||||
add(videoProviders)
|
||||
default:
|
||||
add(providers)
|
||||
add(imageProviders)
|
||||
add(videoProviders)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ai_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"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 TestRegisteredProviders(t *testing.T) {
|
||||
got := ai.RegisteredProviders("")
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("image")
|
||||
want = []string{"atlascloud", "openai"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(image) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("video")
|
||||
want = []string{"atlascloud"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(video) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"openai"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityRows(t *testing.T) {
|
||||
got := ai.CapabilityRows()
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: 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}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityMatrix(t *testing.T) {
|
||||
matrix := ai.CapabilityMatrix()
|
||||
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
|
||||
caps, ok := matrix[provider]
|
||||
if !ok {
|
||||
t.Fatalf("CapabilityMatrix missing %q", provider)
|
||||
}
|
||||
if !caps.Model {
|
||||
t.Fatalf("CapabilityMatrix(%s).Model = false, want true", provider)
|
||||
}
|
||||
}
|
||||
|
||||
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}) {
|
||||
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
|
||||
t.Fatalf("ProviderCapabilities(missing) = %#v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStream(t *testing.T) {
|
||||
ai.RegisterStream("test-stream")
|
||||
|
||||
if caps := ai.ProviderCapabilities("test-stream"); caps != (ai.Capabilities{Stream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(test-stream) = %#v", caps)
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"openai", "test-stream"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -135,7 +135,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for gemini provider")
|
||||
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -88,8 +89,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -119,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 nil, fmt.Errorf("streaming not yet implemented for groq provider")
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package groq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,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 nil, fmt.Errorf("streaming not yet implemented for mistral provider")
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package mistral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -4,6 +4,7 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -112,12 +113,21 @@ const (
|
||||
// RunInfo describes the agent run a tool call belongs to. The agent
|
||||
// attaches it to the context passed to a ToolHandler, so a wrapper can
|
||||
// correlate calls within a run and across delegation without coupling to
|
||||
// the agent package. Per-call detail (tool name, id) is on the ToolCall;
|
||||
// step and attempt counts are naturally counted by the wrapper itself.
|
||||
// the agent package. Flows also attach their name and current step so
|
||||
// tools and agents called from a workflow can be tied back to the
|
||||
// services → agents → workflows lifecycle that invoked them. Per-call
|
||||
// detail (tool name, id) is on the ToolCall. Attempt and MaxAttempts are
|
||||
// set while a model Generate call is in progress, so tools and wrappers can
|
||||
// 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 run (one per Ask)
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
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{}
|
||||
@@ -133,7 +143,12 @@ func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
// ErrStreamingUnsupported is returned by providers that implement the Model
|
||||
// interface but do not yet support token streaming. Use errors.Is so callers
|
||||
// can distinguish an unsupported capability from transient provider failures.
|
||||
var ErrStreamingUnsupported = errors.New("ai: streaming unsupported")
|
||||
|
||||
// Stream is the interface for streaming responses.
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
|
||||
+83
-2
@@ -2,6 +2,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -20,6 +21,7 @@ func init() {
|
||||
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("openai")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
@@ -140,9 +142,88 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
// Stream generates a streaming response from the OpenAI chat completions API.
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/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 &openAIStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type openAIStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *openAIStream) 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"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta.Content == "" {
|
||||
continue
|
||||
}
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *openAIStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
|
||||
@@ -2,6 +2,11 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -80,16 +85,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
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()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
if policy.Timeout > 0 {
|
||||
callCtx, cancel = context.WithTimeout(ctx, policy.Timeout)
|
||||
}
|
||||
if info, ok := RunInfoFrom(callCtx); ok {
|
||||
info.Attempt = attempt
|
||||
info.MaxAttempts = policy.MaxAttempts
|
||||
callCtx = WithRunInfo(callCtx, info)
|
||||
}
|
||||
resp, err := m.Generate(callCtx, req, opts...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type retryModel struct {
|
||||
generate func(context.Context, *Request, ...GenerateOption) (*Response, error)
|
||||
}
|
||||
|
||||
func (m retryModel) Init(...Option) error { return nil }
|
||||
func (m retryModel) Options() Options { return Options{} }
|
||||
func (m retryModel) Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
return m.generate(ctx, req, opts...)
|
||||
}
|
||||
func (m retryModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
|
||||
return nil, ErrStreamingUnsupported
|
||||
}
|
||||
func (m retryModel) String() string { return "retry-test" }
|
||||
|
||||
func TestGenerateWithRetryRetriesTransientErrors(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, errors.New("temporary provider outage")
|
||||
}
|
||||
return &Response{Reply: "ok"}, nil
|
||||
}}
|
||||
|
||||
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("response reply = %q, want ok", resp.Reply)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
cancel()
|
||||
return nil, errors.New("temporary provider outage")
|
||||
}}
|
||||
|
||||
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 3,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}}
|
||||
|
||||
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
Timeout: time.Millisecond,
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
var retryErr *RetryError
|
||||
if !errors.As(err, &retryErr) {
|
||||
t.Fatalf("error = %T %[1]v, want RetryError", err)
|
||||
}
|
||||
if retryErr.Attempts != 2 {
|
||||
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
|
||||
var got []RunInfo
|
||||
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
|
||||
info, ok := RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from attempt context")
|
||||
}
|
||||
got = append(got, info)
|
||||
if info.Attempt == 1 {
|
||||
return nil, errors.New("temporary provider outage")
|
||||
}
|
||||
return &Response{Reply: "ok"}, nil
|
||||
}}
|
||||
|
||||
ctx := WithRunInfo(context.Background(), RunInfo{RunID: "run-1", Agent: "worker"})
|
||||
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithRetry returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("attempt contexts = %d, want 2", len(got))
|
||||
}
|
||||
for i, info := range got {
|
||||
wantAttempt := i + 1
|
||||
if info.Attempt != wantAttempt {
|
||||
t.Fatalf("attempt %d RunInfo.Attempt = %d, want %d", i, info.Attempt, wantAttempt)
|
||||
}
|
||||
if info.MaxAttempts != 2 {
|
||||
t.Fatalf("attempt %d RunInfo.MaxAttempts = %d, want 2", i, info.MaxAttempts)
|
||||
}
|
||||
if info.RunID != "run-1" || info.Agent != "worker" {
|
||||
t.Fatalf("attempt %d RunInfo identity = (%q, %q), want (run-1, worker)", i, info.RunID, info.Agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,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 nil, fmt.Errorf("streaming not yet implemented for together provider")
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package together
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goai "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"
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "ai",
|
||||
Usage: "Inspect AI provider support",
|
||||
Subcommands: []*cli.Command{{
|
||||
Name: "providers",
|
||||
Usage: "Print the registered AI provider capability matrix",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "json",
|
||||
Usage: "Print the capability matrix as JSON",
|
||||
},
|
||||
},
|
||||
Action: providersAction,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func providersAction(c *cli.Context) error {
|
||||
rows := goai.CapabilityRows()
|
||||
if c.Bool("json") {
|
||||
return writeProviderJSON(c.App.Writer, rows)
|
||||
}
|
||||
writeProviderMatrix(c.App.Writer, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProviderJSON(w io.Writer, rows []goai.CapabilityRow) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(rows)
|
||||
}
|
||||
|
||||
func writeProviderMatrix(w io.Writer, rows []goai.CapabilityRow) {
|
||||
const check = "✓"
|
||||
fmt.Fprintln(w, "Provider Model Image Video")
|
||||
fmt.Fprintln(w, "-------- ----- ----- -----")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%-11s %-6s %-6s %-6s\n",
|
||||
row.Provider,
|
||||
mark(row.Model, check),
|
||||
mark(row.Image, check),
|
||||
mark(row.Video, check),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func mark(ok bool, value string) string {
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goai "go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestWriteProviderMatrix(t *testing.T) {
|
||||
rows := []goai.CapabilityRow{
|
||||
{Provider: "atlascloud", Capabilities: goai.Capabilities{Model: true, Image: true, Video: true}},
|
||||
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writeProviderMatrix(&out, rows)
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Provider Model Image Video",
|
||||
"atlascloud ✓ ✓ ✓",
|
||||
"openai ✓ ✓ -",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("matrix output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProviderJSON(t *testing.T) {
|
||||
rows := []goai.CapabilityRow{
|
||||
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeProviderJSON(&out, rows); err != nil {
|
||||
t.Fatalf("writeProviderJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
var got []goai.CapabilityRow
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("JSON output did not decode: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].Provider != "openai" || !got[0].Model || !got[0].Image || got[0].Video {
|
||||
t.Fatalf("decoded JSON = %#v, want openai model+image", got)
|
||||
}
|
||||
if !strings.HasSuffix(out.String(), "\n") {
|
||||
t.Fatalf("JSON output should end with newline: %q", out.String())
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -10,7 +10,9 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -79,6 +81,7 @@ Examples:
|
||||
&cli.StringFlag{Name: "model", Usage: "Model name (uses provider default if unset)", EnvVars: []string{"MICRO_AI_MODEL"}},
|
||||
&cli.StringFlag{Name: "base_url", Usage: "Override the provider's base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
|
||||
&cli.StringFlag{Name: "prompt", Usage: "Send a single prompt and exit (non-interactive)"},
|
||||
&cli.BoolFlag{Name: "stream", Usage: "Stream model output as it is generated when the provider supports it"},
|
||||
},
|
||||
Action: run,
|
||||
})
|
||||
@@ -102,6 +105,7 @@ type session struct {
|
||||
sysPrompt string
|
||||
procs []*exec.Cmd
|
||||
agents map[string]agentInfo
|
||||
stream bool
|
||||
|
||||
// Built-in agent capabilities (plan, delegate), shared with the
|
||||
// agent package so the direct-service fallback has the same tools a
|
||||
@@ -311,6 +315,7 @@ func run(c *cli.Context) error {
|
||||
modelName := c.String("model")
|
||||
baseURL := c.String("base_url")
|
||||
singlePrompt := c.String("prompt")
|
||||
streamOutput := c.Bool("stream")
|
||||
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
@@ -347,6 +352,7 @@ func run(c *cli.Context) error {
|
||||
hist: ai.NewHistory(50),
|
||||
builtinTools: builtinTools,
|
||||
builtinHandle: builtinHandle,
|
||||
stream: streamOutput,
|
||||
}
|
||||
s.refreshTools()
|
||||
|
||||
@@ -449,12 +455,21 @@ func (s *session) ask(ctx context.Context, prompt string) error {
|
||||
// Fallback: direct service access (no agents)
|
||||
s.hist.Add("user", prompt)
|
||||
|
||||
resp, err := s.model.Generate(ctx, &ai.Request{
|
||||
req := &ai.Request{
|
||||
Prompt: prompt,
|
||||
SystemPrompt: s.sysPrompt,
|
||||
Tools: s.toolList,
|
||||
Messages: s.hist.Messages(),
|
||||
})
|
||||
}
|
||||
if s.stream {
|
||||
if err := s.askStream(ctx, req); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.model.Generate(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -489,6 +504,34 @@ func (s *session) ask(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) askStream(ctx context.Context, req *ai.Request) error {
|
||||
stream, err := s.model.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stream.Close()
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Print(chunk.Reply)
|
||||
reply.WriteString(chunk.Reply)
|
||||
}
|
||||
if reply.Len() > 0 {
|
||||
fmt.Println()
|
||||
s.hist.Add("assistant", reply.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeToAgent dispatches a message to the right agent.
|
||||
// If there's only one agent, sends directly. Otherwise uses the
|
||||
// LLM to classify intent and route.
|
||||
|
||||
@@ -4,6 +4,8 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
@@ -17,15 +19,16 @@ func init() {
|
||||
Name: "runs",
|
||||
Usage: "Show recorded agent runs",
|
||||
ArgsUsage: "[agent] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro runs [agent] [run-id]")
|
||||
}
|
||||
if runID := c.Args().Get(1); runID != "" {
|
||||
return printRunHistory(name, runID)
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
return printRunIndex(name)
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -97,13 +100,17 @@ func init() {
|
||||
Name: "history",
|
||||
Usage: "Show an agent's stored conversation and run history",
|
||||
ArgsUsage: "[name] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro agent history [name] [run-id]")
|
||||
}
|
||||
if runID := c.Args().Get(1); runID != "" {
|
||||
return printRunHistory(name, runID)
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
if c.Bool("json") {
|
||||
return printRunIndex(name, runOptions(c), true)
|
||||
}
|
||||
// Read from the agent's scoped state store (database
|
||||
// "agent", table = name) — available whether or not the
|
||||
@@ -117,40 +124,77 @@ func init() {
|
||||
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
|
||||
}
|
||||
}
|
||||
return printRunIndex(name)
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func printRunIndex(name string) error {
|
||||
runs, err := goagent.ListRunSummaries(store.DefaultStore, name)
|
||||
func runFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run data 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 runOptions(c *cli.Context) goagent.RunListOptions {
|
||||
return goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
|
||||
}
|
||||
|
||||
func printRunIndex(name string, opts goagent.RunListOptions, asJSON bool) error {
|
||||
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRunIndex(os.Stdout, name, runs, asJSON)
|
||||
}
|
||||
|
||||
func writeRunIndex(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.Printf(" No runs recorded for agent %q.\n", name)
|
||||
fmt.Fprintf(w, " No runs recorded for agent %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Println(" Runs:")
|
||||
fmt.Fprintln(w, " Runs:")
|
||||
for _, run := range runs {
|
||||
line := fmt.Sprintf(" %s events=%d last=%s updated=%s", run.RunID, run.Events, run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
line := fmt.Sprintf(" %s status=%s events=%d duration=%s last=%s updated=%s", run.RunID, run.Status, run.Events, formatDurationMS(run.DurationMS), run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
if run.ParentID != "" {
|
||||
line += " parent=" + run.ParentID
|
||||
}
|
||||
if run.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(run.TraceID)
|
||||
}
|
||||
if run.LastError != "" {
|
||||
line += " error=" + run.LastError
|
||||
}
|
||||
fmt.Println(line)
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printRunHistory(name, runID string) error {
|
||||
func printRunHistory(name, runID string, asJSON bool) error {
|
||||
events, err := goagent.LoadRunEvents(store.DefaultStore, name, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRunHistory(os.Stdout, name, runID, events, asJSON)
|
||||
}
|
||||
|
||||
func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(events)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
fmt.Printf(" No run %q for agent %q.\n", runID, name)
|
||||
fmt.Fprintf(w, " No run %q for agent %q.\n", runID, name)
|
||||
return nil
|
||||
}
|
||||
for _, e := range events {
|
||||
@@ -167,13 +211,36 @@ func printRunHistory(name, runID string) error {
|
||||
if e.Tokens.TotalTokens > 0 {
|
||||
line += fmt.Sprintf(" tokens=%d", e.Tokens.TotalTokens)
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
line += " parent=" + e.ParentID
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(e.TraceID)
|
||||
}
|
||||
if e.Refused != "" {
|
||||
line += " refused=" + e.Refused
|
||||
}
|
||||
if e.Error != "" {
|
||||
line += " error=" + e.Error
|
||||
}
|
||||
fmt.Println(line)
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDurationMS(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "0ms"
|
||||
}
|
||||
if ms < 1000 {
|
||||
return fmt.Sprintf("%dms", ms)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs", float64(ms)/1000)
|
||||
}
|
||||
|
||||
func shortTraceID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestWriteRunIndexJSON(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
StartedAt: time.Unix(0, 1),
|
||||
UpdatedAt: time.Unix(0, 2),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
TraceID: "1234567890abcdef",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []goagent.RunSummary
|
||||
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].RunID != "run-1" || got[0].LastKind != "tool" {
|
||||
t.Fatalf("decoded summaries = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := out.String()
|
||||
for _, want := range []string{"run-1", "status=done", "events=2", "duration=1.2s", "last=tool", "parent=parent-run"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
events := []goagent.RunEvent{{
|
||||
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
Kind: "tool",
|
||||
Name: "probe",
|
||||
Provider: "oteltest",
|
||||
Model: "unit-model",
|
||||
LatencyMS: 42,
|
||||
Tokens: ai.Usage{TotalTokens: 5},
|
||||
TraceID: "1234567890abcdef",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
|
||||
var human bytes.Buffer
|
||||
if err := writeRunHistory(&human, "runner", "run-1", events, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := human.String()
|
||||
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "parent=parent-run", "trace=1234567890ab"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
}
|
||||
|
||||
var js bytes.Buffer
|
||||
if err := writeRunHistory(&js, "runner", "run-1", events, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []goagent.RunEvent
|
||||
if err := json.Unmarshal(js.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, js.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "probe" || got[0].Tokens.TotalTokens != 5 {
|
||||
t.Fatalf("decoded events = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,45 @@ import (
|
||||
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
|
||||
// it needs the Go toolchain and module access; it is skipped under `-short`.
|
||||
func TestZeroToOneContract(t *testing.T) {
|
||||
generated := generateService(t, "helloworld")
|
||||
|
||||
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
|
||||
if _, err := os.Stat(filepath.Join(generated.dir, rel)); err != nil {
|
||||
t.Fatalf("generated file %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
}
|
||||
|
||||
// TestZeroToOneNoMCPContract keeps the MCP opt-out path honest. Some services
|
||||
// intentionally run without the local MCP listener, but that variant must still
|
||||
// satisfy the same 0→1 contract: scaffold, tidy, and build without additional
|
||||
// toolchain dependencies.
|
||||
func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
generated := generateService(t, "worker", "--no-mcp")
|
||||
|
||||
main, err := os.ReadFile(filepath.Join(generated.dir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(main), "gateway/mcp") || strings.Contains(string(main), "WithMCP") {
|
||||
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
dir string
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
func generateService(t *testing.T, name string, args ...string) generatedService {
|
||||
t.Helper()
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("contract test shells out to the Go toolchain; skipped with -short")
|
||||
}
|
||||
@@ -39,35 +78,44 @@ func TestZeroToOneContract(t *testing.T) {
|
||||
defer os.Chdir(oldwd)
|
||||
|
||||
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
|
||||
if err := set.Parse([]string{"helloworld"}); err != nil {
|
||||
set.Bool("no-mcp", false, "")
|
||||
set.Bool("proto", false, "")
|
||||
set.String("template", "", "")
|
||||
set.String("prompt", "", "")
|
||||
set.String("provider", "", "")
|
||||
set.String("api_key", "", "")
|
||||
if err := set.Parse(append(args, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := cli.NewContext(cli.NewApp(), set, nil)
|
||||
|
||||
if err := Run(ctx); err != nil {
|
||||
t.Fatalf("micro new helloworld: %v", err)
|
||||
t.Fatalf("micro new %s %s: %v", strings.Join(args, " "), name, err)
|
||||
}
|
||||
|
||||
serviceDir := filepath.Join(tmp, "helloworld")
|
||||
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
|
||||
if _, err := os.Stat(filepath.Join(serviceDir, rel)); err != nil {
|
||||
t.Fatalf("generated file %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
modPath := filepath.Join(serviceDir, "go.mod")
|
||||
func (g generatedService) replaceModule(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
modPath := filepath.Join(g.dir, "go.mod")
|
||||
mod, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
|
||||
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) build(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = serviceDir
|
||||
cmd.Dir = g.dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
|
||||
|
||||
+99
-2
@@ -6,8 +6,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -72,7 +74,14 @@ Examples:
|
||||
Name: "runs",
|
||||
Usage: "Show durable run history for a flow",
|
||||
ArgsUsage: "[name]",
|
||||
Action: flowRuns,
|
||||
Flags: []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"},
|
||||
},
|
||||
Action: flowRuns,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -125,10 +134,83 @@ func flowRuns(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts, err := validateFlowRunOptions(flowRunOptions{Pending: c.Bool("pending"), Status: c.String("status"), Stage: c.String("stage"), Limit: c.Int("limit")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runs = filterFlowRuns(runs, opts)
|
||||
if len(runs) == 0 {
|
||||
if c.Bool("pending") {
|
||||
fmt.Printf(" No pending runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" No runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
return writeFlowRuns(os.Stdout, runs, c.Bool("json"))
|
||||
}
|
||||
|
||||
type flowRunOptions struct {
|
||||
Pending bool
|
||||
Status string
|
||||
Stage string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func validateFlowRunOptions(opts flowRunOptions) (flowRunOptions, error) {
|
||||
switch opts.Status {
|
||||
case "", "running", "done", "failed":
|
||||
default:
|
||||
return opts, fmt.Errorf("invalid run status %q: expected running, done, or failed", opts.Status)
|
||||
}
|
||||
if opts.Limit < 0 {
|
||||
return opts, fmt.Errorf("invalid limit %d: expected a non-negative value", opts.Limit)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func filterFlowRuns(runs []aiflow.Run, opts flowRunOptions) []aiflow.Run {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]aiflow.Run, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
if opts.Pending && run.Status == "done" {
|
||||
continue
|
||||
}
|
||||
if opts.Status != "" && run.Status != opts.Status {
|
||||
continue
|
||||
}
|
||||
if opts.Stage != "" && run.State.Stage != opts.Stage {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, run)
|
||||
}
|
||||
if opts.Limit > 0 && len(filtered) > opts.Limit {
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
if filtered[i].Updated.Equal(filtered[j].Updated) {
|
||||
return filtered[i].Started.Before(filtered[j].Started)
|
||||
}
|
||||
return filtered[i].Updated.Before(filtered[j].Updated)
|
||||
})
|
||||
start := len(filtered) - opts.Limit
|
||||
limited := append([]aiflow.Run(nil), filtered[start:]...)
|
||||
return limited
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func pendingFlowRuns(runs []aiflow.Run) []aiflow.Run {
|
||||
return filterFlowRuns(runs, flowRunOptions{Pending: true})
|
||||
}
|
||||
|
||||
func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
fmt.Fprintf(w, " %d run%s\n", len(runs), plural(len(runs)))
|
||||
for _, r := range runs {
|
||||
id := r.ID
|
||||
if len(id) > 8 {
|
||||
@@ -138,11 +220,26 @@ func flowRuns(c *cli.Context) error {
|
||||
if stage == "" {
|
||||
stage = "-"
|
||||
}
|
||||
fmt.Printf(" %s %-8s stage=%-12s (%d steps)\n", id, r.Status, stage, len(r.Steps))
|
||||
fmt.Fprintf(w, " %s %-8s stage=%-12s updated=%s (%d steps)\n",
|
||||
id, r.Status, stage, r.Updated.Format("2006-01-02T15:04:05Z07:00"), len(r.Steps))
|
||||
for _, step := range r.Steps {
|
||||
fmt.Fprintf(w, " - %-12s %-11s attempts=%d", step.Name, step.Status, step.Attempts)
|
||||
if step.Error != "" {
|
||||
fmt.Fprintf(w, " error=%q", step.Error)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func flowFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{Name: "trigger", Usage: "Broker topic to subscribe to", EnvVars: []string{"MICRO_FLOW_TRIGGER"}},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
|
||||
updated := time.Date(2026, 6, 24, 12, 30, 0, 0, time.UTC)
|
||||
runs := []aiflow.Run{{
|
||||
ID: "1234567890abcdef",
|
||||
Status: "failed",
|
||||
Updated: updated,
|
||||
State: aiflow.State{Stage: "charge"},
|
||||
Steps: []aiflow.StepRecord{
|
||||
{Name: "reserve", Status: "done", Attempts: 1},
|
||||
{Name: "charge", Status: "failed", Attempts: 3, Error: "card declined"},
|
||||
},
|
||||
}}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowRuns(&out, runs, false); err != nil {
|
||||
t.Fatalf("writeFlowRuns: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"1 run",
|
||||
"12345678 failed stage=charge",
|
||||
"updated=2026-06-24T12:30:00Z",
|
||||
"- reserve done attempts=1",
|
||||
`- charge failed attempts=3 error="card declined"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsInvalidStatus(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Status: "stuck"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid run status") {
|
||||
t.Fatalf("expected invalid status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsNegativeLimit(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Limit: -1})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid limit") {
|
||||
t.Fatalf("expected invalid limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFlowRunsJSON(t *testing.T) {
|
||||
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowRuns(&out, runs, true); err != nil {
|
||||
t.Fatalf("writeFlowRuns: %v", 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].Flow != "checkout" || got[0].Status != "done" {
|
||||
t.Fatalf("decoded runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingFlowRunsFiltersCompletedRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := pendingFlowRuns(runs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pendingFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("pending runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStatus(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
{ID: "run-4", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Status: "failed"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-4" {
|
||||
t.Fatalf("failed runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStage(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed", State: aiflow.State{Stage: "reserve"}},
|
||||
{ID: "run-2", Status: "failed", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-3", Status: "running", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-4", Status: "done", State: aiflow.State{}},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Stage: "charge"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("charge-stage runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsLimitKeepsNewestRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("limited runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsCombinesPendingStatusAndLimit(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed"},
|
||||
{ID: "run-2", Status: "done"},
|
||||
{ID: "run-3", Status: "failed"},
|
||||
{ID: "run-4", Status: "running"},
|
||||
{ID: "run-5", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Pending: true, Status: "failed", Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-3" || got[1].ID != "run-5" {
|
||||
t.Fatalf("filtered runs = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"go-micro.dev/v6/cmd"
|
||||
|
||||
_ "go-micro.dev/v6/cmd/micro/a2a"
|
||||
_ "go-micro.dev/v6/cmd/micro/ai"
|
||||
_ "go-micro.dev/v6/cmd/micro/api"
|
||||
_ "go-micro.dev/v6/cmd/micro/chat"
|
||||
_ "go-micro.dev/v6/cmd/micro/cli"
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@ package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
@@ -25,11 +26,17 @@ func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface
|
||||
func TestExecuteDispatchesToAgent(t *testing.T) {
|
||||
f := New("welcome", Agent("comms"), Prompt("welcome {{.Data}}"))
|
||||
|
||||
var svc, ep string
|
||||
var svc, ep, parentID string
|
||||
f.client = &fakeClient{
|
||||
Client: client.DefaultClient,
|
||||
callFn: func(req client.Request, rsp interface{}) error {
|
||||
svc, ep = 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"]
|
||||
frame := rsp.(*codecbytes.Frame)
|
||||
frame.Data = []byte(`{"reply":"welcomed bob","agent":"comms"}`)
|
||||
return nil
|
||||
@@ -43,6 +50,9 @@ func TestExecuteDispatchesToAgent(t *testing.T) {
|
||||
if svc != "comms" || ep != "Agent.Chat" {
|
||||
t.Errorf("dispatched to %s.%s, want comms.Agent.Chat", svc, ep)
|
||||
}
|
||||
if parentID == "" {
|
||||
t.Fatal("dispatch request parent_id is empty")
|
||||
}
|
||||
|
||||
results := f.Results()
|
||||
if len(results) != 1 {
|
||||
|
||||
+21
-1
@@ -182,6 +182,19 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
|
||||
// registry. In-flight and past runs remain in the store; Stop only ends
|
||||
// the flow's liveness, mirroring how a service leaves the registry when
|
||||
// it shuts down.
|
||||
func (f *Flow) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if f.opts.Timeout <= 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, f.opts.Timeout)
|
||||
}
|
||||
|
||||
func (f *Flow) Stop() error {
|
||||
if f.sub != nil {
|
||||
_ = f.sub.Unsubscribe()
|
||||
@@ -199,12 +212,18 @@ func (f *Flow) Stop() error {
|
||||
// called automatically on each broker event, but can also be
|
||||
// invoked directly for testing or one-shot use.
|
||||
func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Stepped flows run the ordered, checkpointed step loop.
|
||||
if len(f.opts.Steps) > 0 {
|
||||
_, err := f.startRun(ctx, data)
|
||||
return err
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
prompt := data
|
||||
@@ -277,7 +296,8 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
// callAgent hands the rendered prompt to a registered agent's Agent.Chat
|
||||
// endpoint over RPC and returns its reply.
|
||||
func (f *Flow) callAgent(ctx context.Context, name, message string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"message": message})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
body, _ := json.Marshal(map[string]string{"message": message, "parent_id": info.RunID})
|
||||
req := f.client.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
|
||||
var rsp codecbytes.Frame
|
||||
if err := f.client.Call(ctx, req, &rsp); err != nil {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -83,3 +87,45 @@ func TestDefaultOptions(t *testing.T) {
|
||||
t.Error("default system prompt is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleStepFlowRunInfoIdentifiesFlow(t *testing.T) {
|
||||
model := &runInfoModel{}
|
||||
f := New("single-observed")
|
||||
f.model = model
|
||||
f.toolSet = ai.NewTools(registry.NewMemoryRegistry())
|
||||
|
||||
if err := f.Execute(context.Background(), "observe me"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if model.got.RunID == "" {
|
||||
t.Fatal("RunInfo.RunID is empty")
|
||||
}
|
||||
if model.got.Flow != "single-observed" {
|
||||
t.Fatalf("RunInfo.Flow = %q, want single-observed", model.got.Flow)
|
||||
}
|
||||
if model.got.Agent != "" {
|
||||
t.Fatalf("RunInfo.Agent = %q, want empty for flow-owned LLM run", model.got.Agent)
|
||||
}
|
||||
if model.got.Step != "" {
|
||||
t.Fatalf("RunInfo.Step = %q, want empty for single-step flow", model.got.Step)
|
||||
}
|
||||
}
|
||||
|
||||
type runInfoModel struct {
|
||||
got ai.RunInfo
|
||||
}
|
||||
|
||||
func (m *runInfoModel) Init(...ai.Option) error { return nil }
|
||||
|
||||
func (m *runInfoModel) Options() ai.Options { return ai.Options{} }
|
||||
|
||||
func (m *runInfoModel) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
m.got, _ = ai.RunInfoFrom(ctx)
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
|
||||
func (m *runInfoModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
func (m *runInfoModel) String() string { return "run-info-model" }
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Options configures a Flow.
|
||||
type Options struct {
|
||||
// TriggerTopic is the broker topic that triggers this flow.
|
||||
@@ -18,6 +24,9 @@ type Options struct {
|
||||
BaseURL string
|
||||
// HistoryLimit is the max messages per flow execution.
|
||||
HistoryLimit int
|
||||
// Timeout bounds one flow execution when the caller did not already
|
||||
// provide a context deadline. Zero means no flow-level timeout.
|
||||
Timeout time.Duration
|
||||
// OnResult is called after each execution with the result.
|
||||
OnResult func(Result)
|
||||
// Agent, if set, names a registered agent the flow hands each event
|
||||
@@ -32,9 +41,14 @@ type Options struct {
|
||||
// Retry is the flow-level retry count applied to each step (0 = no
|
||||
// retry). A Step's own Retry field overrides this.
|
||||
Retry int
|
||||
// RetryBackoff is the delay between failed step attempts. Zero means
|
||||
// retry immediately; cancellation/deadline stops the wait early.
|
||||
RetryBackoff time.Duration
|
||||
// Checkpoint is the durability backend for stepped runs. Nil with
|
||||
// steps present means a store-backed default; set it to swap backends.
|
||||
Checkpoint Checkpoint
|
||||
// TraceProvider emits OpenTelemetry spans for stepped flow runs.
|
||||
TraceProvider trace.TracerProvider
|
||||
// DeleteOnSuccess removes a run's checkpoint when it completes
|
||||
// successfully. Failed runs are always retained. Default: retain all.
|
||||
DeleteOnSuccess bool
|
||||
@@ -83,6 +97,13 @@ func HistoryLimit(n int) Option {
|
||||
return func(o *Options) { o.HistoryLimit = n }
|
||||
}
|
||||
|
||||
// Timeout bounds one flow execution when the caller did not already
|
||||
// provide a context deadline. It applies to broker-triggered runs,
|
||||
// Execute calls, resumed stepped runs, and retry backoff waits.
|
||||
func Timeout(d time.Duration) Option {
|
||||
return func(o *Options) { o.Timeout = d }
|
||||
}
|
||||
|
||||
// OnResult sets a callback for each execution result.
|
||||
func OnResult(fn func(Result)) Option {
|
||||
return func(o *Options) { o.OnResult = fn }
|
||||
@@ -108,6 +129,13 @@ func Retry(n int) Option {
|
||||
return func(o *Options) { o.Retry = n }
|
||||
}
|
||||
|
||||
// RetryBackoff sets the delay between failed step attempts. A zero
|
||||
// duration preserves immediate retries. If the run context is canceled
|
||||
// while waiting, the context error is returned instead of retrying.
|
||||
func RetryBackoff(d time.Duration) Option {
|
||||
return func(o *Options) { o.RetryBackoff = d }
|
||||
}
|
||||
|
||||
// WithCheckpoint sets the durability backend. With a checkpoint, a run is
|
||||
// persisted before and after each step and can be resumed after a crash.
|
||||
// Stepped flows default to a store-backed checkpoint; use this to swap it.
|
||||
@@ -120,3 +148,8 @@ func WithCheckpoint(c Checkpoint) Option {
|
||||
func DeleteOnSuccess() Option {
|
||||
return func(o *Options) { o.DeleteOnSuccess = true }
|
||||
}
|
||||
|
||||
// TraceProvider enables OpenTelemetry spans for stepped flow runs and steps.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const flowInstrumentationName = "go-micro.dev/v6/flow"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (f *Flow) tracer() trace.Tracer {
|
||||
return f.opts.TraceProvider.Tracer(flowInstrumentationName)
|
||||
}
|
||||
|
||||
func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func(Run, error)) {
|
||||
if f.opts.TraceProvider == nil {
|
||||
return ctx, func(Run, error) {}
|
||||
}
|
||||
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),
|
||||
))
|
||||
start := time.Now()
|
||||
return ctx, func(done Run, err error) {
|
||||
span.SetAttributes(
|
||||
attribute.String(AttrFlowStatus, done.Status),
|
||||
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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),
|
||||
))
|
||||
start := time.Now()
|
||||
out, attempts, err := f.runStep(ctx, step, in)
|
||||
span.SetAttributes(
|
||||
attribute.Int(AttrFlowAttempts, attempts),
|
||||
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
return out, attempts, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
|
||||
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
|
||||
in.Data = []byte("done")
|
||||
return in, nil
|
||||
}}
|
||||
f := New("observed", WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "observed")), TraceProvider(tp), Steps(step))
|
||||
ctx := withTestRunInfo(context.Background(), "agent-run-otel")
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
seen := map[string]bool{spanNameFlowRun: false, spanNameFlowStep: false}
|
||||
var runID string
|
||||
for _, span := range spans {
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
switch span.Name() {
|
||||
case spanNameFlowRun:
|
||||
seen[spanNameFlowRun] = true
|
||||
runID = attrs[AttrFlowRunID]
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("run span attributes = %#v", attrs)
|
||||
}
|
||||
case spanNameFlowStep:
|
||||
seen[spanNameFlowStep] = true
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("step span attributes = %#v", attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
|
||||
}
|
||||
}
|
||||
if runID == "" {
|
||||
t.Fatal("run span missing run id")
|
||||
}
|
||||
for _, span := range spans {
|
||||
if span.Name() != spanNameFlowStep {
|
||||
continue
|
||||
}
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
if attrs[AttrFlowRunID] != runID {
|
||||
t.Fatalf("step span run id = %q, want %q", attrs[AttrFlowRunID], runID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
out[string(attr.Key)] = attr.Value.AsString()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withTestRunInfo(ctx context.Context, runID string) context.Context {
|
||||
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
|
||||
}
|
||||
+126
-24
@@ -74,13 +74,14 @@ type StepRecord struct {
|
||||
// saves and loads. It is retained for success and failure unless the flow
|
||||
// opts into cleanup with DeleteOnSuccess.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
Flow string `json:"flow"`
|
||||
State State `json:"state"`
|
||||
Steps []StepRecord `json:"steps"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Flow string `json:"flow"`
|
||||
State State `json:"state"`
|
||||
Steps []StepRecord `json:"steps"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// Checkpoint persists and restores flow runs so a run survives a crash
|
||||
@@ -111,16 +112,25 @@ func StoreCheckpoint(s store.Store, scope string) Checkpoint {
|
||||
return &storeCheckpoint{store: store.Scope(s, "flow", scope)}
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Save(_ context.Context, run Run) error {
|
||||
func (c *storeCheckpoint) Save(ctx context.Context, run Run) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
run.Updated = time.Now()
|
||||
b, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.store.Write(&store.Record{Key: run.ID, Value: b})
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Load(_ context.Context, runID string) (Run, bool, error) {
|
||||
func (c *storeCheckpoint) Load(ctx context.Context, runID string) (Run, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Run{}, false, err
|
||||
}
|
||||
recs, err := c.store.Read(runID)
|
||||
if err == store.ErrNotFound || len(recs) == 0 {
|
||||
return Run{}, false, nil
|
||||
@@ -135,11 +145,17 @@ func (c *storeCheckpoint) Load(_ context.Context, runID string) (Run, bool, erro
|
||||
return run, true, nil
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Delete(_ context.Context, runID string) error {
|
||||
func (c *storeCheckpoint) Delete(ctx context.Context, runID string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.store.Delete(runID)
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) List(ctx context.Context) ([]Run, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := c.store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -225,7 +241,8 @@ func Dispatch(name string) StepFunc {
|
||||
if d := depsFrom(ctx); d != nil && d.client != nil {
|
||||
cl = d.client
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"message": in.String()})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
body, _ := json.Marshal(map[string]string{"message": in.String(), "parent_id": info.RunID})
|
||||
req := cl.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
|
||||
var rsp codecbytes.Frame
|
||||
if err := cl.Call(ctx, req, &rsp); err != nil {
|
||||
@@ -290,12 +307,20 @@ func LLM(prompt string) StepFunc {
|
||||
|
||||
// startRun begins a fresh run of the flow's steps with the given input.
|
||||
func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
parentID := ""
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
parentID = info.RunID
|
||||
}
|
||||
run := Run{
|
||||
ID: uuid.New().String(),
|
||||
Flow: f.name,
|
||||
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
|
||||
Status: "running",
|
||||
Started: time.Now(),
|
||||
ID: uuid.New().String(),
|
||||
ParentID: parentID,
|
||||
Flow: f.name,
|
||||
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
|
||||
Status: "running",
|
||||
Started: time.Now(),
|
||||
}
|
||||
for _, s := range f.opts.Steps {
|
||||
run.Steps = append(run.Steps, StepRecord{Name: s.Name, Status: "pending"})
|
||||
@@ -306,6 +331,12 @@ func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
|
||||
// Resume continues a persisted run by id, picking up at the step it
|
||||
// stopped on. Completed runs are a no-op.
|
||||
func (f *Flow) Resume(ctx context.Context, runID string) error {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.checkpoint == nil {
|
||||
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
|
||||
}
|
||||
@@ -323,6 +354,30 @@ func (f *Flow) Resume(ctx context.Context, runID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumePending resumes every checkpointed run for this flow that has not
|
||||
// completed yet, in the same oldest-first order returned by Pending.
|
||||
//
|
||||
// It is a convenience for service startup and recovery loops: after a process
|
||||
// restart, call ResumePending to drain the durable backlog without having to
|
||||
// list and resume each run manually. If any run fails again, ResumePending
|
||||
// stops and returns that run id with the error so callers can log, alert, or
|
||||
// retry later without hiding the failing run.
|
||||
func (f *Flow) ResumePending(ctx context.Context) (string, error) {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
runs, err := f.Pending(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, run := range runs {
|
||||
if err := f.Resume(ctx, run.ID); err != nil {
|
||||
return run.ID, err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Pending returns this flow's runs that have not completed — the ones a
|
||||
// process restart should resume.
|
||||
func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
@@ -347,6 +402,10 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
|
||||
start := stepIndex(steps, run.State.Stage)
|
||||
if start < 0 {
|
||||
@@ -361,15 +420,22 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
step := steps[i]
|
||||
run.State.Stage = step.Name
|
||||
run.Steps[i].Status = "in_progress"
|
||||
f.save(ctx, run)
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
|
||||
out, attempts, err := f.runStep(ctx, step, run.State)
|
||||
out, attempts, err := f.runStepSpan(ctx, step, run.State)
|
||||
run.Steps[i].Attempts = attempts
|
||||
if err != nil {
|
||||
spanErr = err
|
||||
run.Steps[i].Status = "failed"
|
||||
run.Steps[i].Error = err.Error()
|
||||
run.Status = "failed"
|
||||
f.save(ctx, run)
|
||||
if saveErr := f.save(ctx, run); saveErr != nil {
|
||||
spanErr = saveErr
|
||||
return run, fmt.Errorf("%w; additionally failed to checkpoint failed run: %v", err, saveErr)
|
||||
}
|
||||
f.record(resultFromRun(f.opts.TriggerTopic, run))
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s run %s failed at step %q: %v", f.name, run.ID, step.Name, err)
|
||||
return run, err
|
||||
@@ -383,13 +449,22 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
} else {
|
||||
run.State.Stage = ""
|
||||
}
|
||||
f.save(ctx, run)
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
}
|
||||
|
||||
run.Status = "done"
|
||||
f.save(ctx, run)
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
if f.opts.DeleteOnSuccess && f.checkpoint != nil {
|
||||
_ = f.checkpoint.Delete(ctx, run.ID)
|
||||
if err := f.checkpoint.Delete(ctx, run.ID); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
}
|
||||
f.record(resultFromRun(f.opts.TriggerTopic, run))
|
||||
f.log.Logf(logger.InfoLevel, "Flow %s run %s completed (%d steps)", f.name, run.ID, len(steps))
|
||||
@@ -415,22 +490,49 @@ func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, er
|
||||
if err := ctx.Err(); err != nil {
|
||||
return in, attempt - 1, err
|
||||
}
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
info.Step = step.Name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
}
|
||||
out, err := step.Run(ctx, in)
|
||||
if err == nil {
|
||||
return out, attempt, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt <= retries && f.opts.RetryBackoff > 0 {
|
||||
select {
|
||||
case <-time.After(f.opts.RetryBackoff):
|
||||
case <-ctx.Done():
|
||||
return in, attempt, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return in, retries + 1, lastErr
|
||||
}
|
||||
|
||||
func (f *Flow) save(ctx context.Context, run Run) {
|
||||
func (f *Flow) save(ctx context.Context, run Run) error {
|
||||
if f.checkpoint == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := f.checkpoint.Save(ctx, run); err != nil {
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s checkpoint save: %v", f.name, err)
|
||||
return fmt.Errorf("flow %s checkpoint save: %w", f.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSteps(steps []Step) error {
|
||||
seen := make(map[string]struct{}, len(steps))
|
||||
for i, step := range steps {
|
||||
if step.Name == "" {
|
||||
return fmt.Errorf("flow: step %d has an empty name", i)
|
||||
}
|
||||
if _, ok := seen[step.Name]; ok {
|
||||
return fmt.Errorf("flow: duplicate step name %q", step.Name)
|
||||
}
|
||||
seen[step.Name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepIndex(steps []Step, name string) int {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
@@ -86,6 +87,125 @@ func TestFlowCheckpointResume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
var got ai.RunInfo
|
||||
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
|
||||
var ok bool
|
||||
got, ok = ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from step context")
|
||||
}
|
||||
in.Data = []byte("ok")
|
||||
return in, nil
|
||||
}}
|
||||
|
||||
mem := store.NewMemoryStore()
|
||||
f := New("correlated",
|
||||
WithCheckpoint(StoreCheckpoint(mem, "correlated")),
|
||||
Steps(step),
|
||||
)
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "agent-run-1", Agent: "planner"})
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got.Agent != "correlated" {
|
||||
t.Fatalf("RunInfo.Agent = %q, want correlated", got.Agent)
|
||||
}
|
||||
if got.RunID == "" {
|
||||
t.Fatal("RunInfo.RunID is empty")
|
||||
}
|
||||
if got.Flow != "correlated" {
|
||||
t.Fatalf("RunInfo.Flow = %q, want correlated", got.Flow)
|
||||
}
|
||||
if got.ParentID != "agent-run-1" {
|
||||
t.Fatalf("RunInfo.ParentID = %q, want agent-run-1", got.ParentID)
|
||||
}
|
||||
if got.Step != "inspect" {
|
||||
t.Fatalf("RunInfo.Step = %q, want inspect", got.Step)
|
||||
}
|
||||
runs, err := StoreCheckpoint(mem, "correlated").List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ParentID != "agent-run-1" {
|
||||
t.Fatalf("persisted parent id = %+v, want agent-run-1", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowResumePendingResumesOldestRunsUntilFailure(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
ctx := context.Background()
|
||||
var calls int
|
||||
step := Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
|
||||
calls++
|
||||
if in.String() == "block" {
|
||||
return in, errors.New("still blocked")
|
||||
}
|
||||
in.Data = []byte(in.String() + "-done")
|
||||
return in, nil
|
||||
}}
|
||||
f := New("resume-pending", WithCheckpoint(StoreCheckpoint(mem, "resume-pending")), Steps(step))
|
||||
|
||||
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||
runs := []Run{
|
||||
{
|
||||
ID: "run-ok",
|
||||
Flow: "resume-pending",
|
||||
State: State{Stage: "work", Data: []byte("ok")},
|
||||
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
|
||||
Status: "failed",
|
||||
Started: base,
|
||||
},
|
||||
{
|
||||
ID: "run-blocked",
|
||||
Flow: "resume-pending",
|
||||
State: State{Stage: "work", Data: []byte("block")},
|
||||
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
|
||||
Status: "failed",
|
||||
Started: base.Add(time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "run-later",
|
||||
Flow: "resume-pending",
|
||||
State: State{Stage: "work", Data: []byte("later")},
|
||||
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
|
||||
Status: "failed",
|
||||
Started: base.Add(2 * time.Minute),
|
||||
},
|
||||
}
|
||||
for _, run := range runs {
|
||||
if err := f.checkpoint.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save(%s): %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
failedRun, err := f.ResumePending(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected ResumePending to stop at the blocked run")
|
||||
}
|
||||
if failedRun != "run-blocked" {
|
||||
t.Fatalf("failed run = %q, want run-blocked", failedRun)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("ResumePending should stop before later runs; got %d calls", calls)
|
||||
}
|
||||
|
||||
run, ok, err := f.checkpoint.Load(ctx, "run-ok")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load(run-ok) ok=%v err=%v", ok, err)
|
||||
}
|
||||
if run.Status != "done" || run.State.String() != "ok-done" {
|
||||
t.Fatalf("run-ok not resumed successfully: %+v", run)
|
||||
}
|
||||
run, ok, err = f.checkpoint.Load(ctx, "run-later")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load(run-later) ok=%v err=%v", ok, err)
|
||||
}
|
||||
if run.Status != "failed" {
|
||||
t.Fatalf("run-later should not be resumed after a failure, got %+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
// A flow-level Retry re-runs a failing step until it succeeds.
|
||||
func TestFlowStepRetry(t *testing.T) {
|
||||
var attempts int
|
||||
@@ -130,6 +250,106 @@ func TestFlowStepRetryOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepRetryBackoffWaitsBetweenAttempts(t *testing.T) {
|
||||
var attempts int
|
||||
step := Step{Name: "transient", Run: func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return in, errors.New("transient")
|
||||
}
|
||||
in.Data = []byte("ok")
|
||||
return in, nil
|
||||
}}
|
||||
|
||||
f := New("retry-backoff",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "retry-backoff")),
|
||||
Retry(1),
|
||||
RetryBackoff(10*time.Millisecond),
|
||||
Steps(step),
|
||||
)
|
||||
start := time.Now()
|
||||
if err := f.Execute(context.Background(), ""); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("want 2 attempts, got %d", attempts)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
|
||||
t.Fatalf("retry backoff was not observed; elapsed %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowTimeoutStopsRetryBackoff(t *testing.T) {
|
||||
var attempts int
|
||||
step := Step{Name: "slow", Run: func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
return in, errors.New("transient")
|
||||
}}
|
||||
|
||||
f := New("timeout-backoff",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "timeout-backoff")),
|
||||
Timeout(20*time.Millisecond),
|
||||
Retry(1),
|
||||
RetryBackoff(time.Hour),
|
||||
Steps(step),
|
||||
)
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected the timed-out run to fail")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("want a context deadline error, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("timeout should stop during backoff before retrying, got %d attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowTimeoutRespectsExistingDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
|
||||
f := New("existing-deadline", Timeout(time.Millisecond))
|
||||
got, stop := f.withTimeout(ctx)
|
||||
defer stop()
|
||||
|
||||
wantDeadline, _ := ctx.Deadline()
|
||||
gotDeadline, ok := got.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected the existing deadline to remain set")
|
||||
}
|
||||
if !gotDeadline.Equal(wantDeadline) {
|
||||
t.Fatalf("deadline = %v, want existing deadline %v", gotDeadline, wantDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepRetryBackoffStopsOnCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var attempts int
|
||||
step := Step{Name: "cancelbackoff", Run: func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
cancel()
|
||||
return in, errors.New("transient")
|
||||
}}
|
||||
|
||||
f := New("cancel-backoff",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "cancel-backoff")),
|
||||
Retry(1),
|
||||
RetryBackoff(time.Hour),
|
||||
Steps(step),
|
||||
)
|
||||
err := f.Execute(ctx, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected the canceled run to fail")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("want a context.Canceled error, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("cancellation should stop during backoff before retrying, got %d attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// A canceled run stops retrying immediately instead of burning the whole
|
||||
// retry budget, and surfaces the context error.
|
||||
func TestFlowStepRetryStopsOnCancel(t *testing.T) {
|
||||
@@ -160,6 +380,41 @@ func TestFlowStepRetryStopsOnCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepNamesMustBeUnique(t *testing.T) {
|
||||
step := Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
|
||||
return in, nil
|
||||
}}
|
||||
f := New("duplicate-steps",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "duplicate-steps")),
|
||||
Steps(step, step),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate step names to fail validation")
|
||||
}
|
||||
if got, want := err.Error(), `flow: duplicate step name "work"`; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepNamesMustBeNonEmpty(t *testing.T) {
|
||||
f := New("empty-step-name",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "empty-step-name")),
|
||||
Steps(Step{Name: "", Run: func(_ context.Context, in State) (State, error) {
|
||||
return in, nil
|
||||
}}),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an empty step name to fail validation")
|
||||
}
|
||||
if got, want := err.Error(), "flow: step 0 has an empty name"; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A step with no Run function is reported as a configuration error rather
|
||||
// than panicking the run.
|
||||
func TestFlowStepNilRun(t *testing.T) {
|
||||
@@ -203,6 +458,80 @@ func TestStoreCheckpointListReturnsRunsInStartedOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCheckpointHonorsCanceledContext(t *testing.T) {
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "canceled")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
run := Run{ID: "canceled", Started: time.Now()}
|
||||
|
||||
if err := cp.Save(ctx, run); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Save error = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, ok, err := cp.Load(ctx, run.ID); !errors.Is(err, context.Canceled) || ok {
|
||||
t.Fatalf("Load ok, error = %v, %v; want false, context.Canceled", ok, err)
|
||||
}
|
||||
if err := cp.Delete(ctx, run.ID); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Delete error = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := cp.List(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("List error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingCheckpoint struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c failingCheckpoint) Save(context.Context, Run) error { return c.err }
|
||||
func (c failingCheckpoint) Load(context.Context, string) (Run, bool, error) {
|
||||
return Run{}, false, c.err
|
||||
}
|
||||
func (c failingCheckpoint) Delete(context.Context, string) error { return c.err }
|
||||
func (c failingCheckpoint) List(context.Context) ([]Run, error) { return nil, c.err }
|
||||
|
||||
func TestFlowCheckpointSaveFailureStopsRun(t *testing.T) {
|
||||
checkpointErr := errors.New("checkpoint unavailable")
|
||||
var ran bool
|
||||
f := New("checkpoint-fails",
|
||||
WithCheckpoint(failingCheckpoint{err: checkpointErr}),
|
||||
Steps(Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
|
||||
ran = true
|
||||
return in, nil
|
||||
}}),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "start")
|
||||
if !errors.Is(err, checkpointErr) {
|
||||
t.Fatalf("Execute error = %v, want checkpoint error", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("step ran even though the in-progress checkpoint failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowDeleteOnSuccessFailureIsReturned(t *testing.T) {
|
||||
checkpointErr := errors.New("delete unavailable")
|
||||
cp := &deleteFailCheckpoint{Checkpoint: StoreCheckpoint(store.NewMemoryStore(), "delete-fails"), err: checkpointErr}
|
||||
f := New("delete-fails",
|
||||
WithCheckpoint(cp),
|
||||
DeleteOnSuccess(),
|
||||
Steps(appendStep("work")),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if !errors.Is(err, checkpointErr) {
|
||||
t.Fatalf("Execute error = %v, want delete error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type deleteFailCheckpoint struct {
|
||||
Checkpoint
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *deleteFailCheckpoint) Delete(context.Context, string) error { return c.err }
|
||||
|
||||
func TestStateSetScan(t *testing.T) {
|
||||
var s State
|
||||
type payload struct {
|
||||
|
||||
+306
-32
@@ -18,16 +18,18 @@
|
||||
// BaseURL: "https://agents.example.com",
|
||||
// })
|
||||
//
|
||||
// Scope of this version: the synchronous JSON-RPC binding — `message/send`
|
||||
// (returns a completed Task), `tasks/get`, and Agent Card discovery.
|
||||
// Streaming (`message/stream`), multi-turn `input-required`, and push
|
||||
// notifications are advertised as unsupported and are follow-ups.
|
||||
// Scope of this version: the JSON-RPC binding — `message/send`
|
||||
// (returns a completed Task), `message/stream` (SSE with the completed
|
||||
// Task event), `tasks/get`, multi-turn task continuation, push
|
||||
// notification delivery, and Agent Card discovery. `input-required` and
|
||||
// `tasks/resubscribe` are advertised as unsupported and are follow-ups.
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -35,6 +37,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
codecbytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -91,6 +94,9 @@ func New(opts Options) *Gateway {
|
||||
// to Agent.Chat (the gateway) or an in-process Ask (an embedded agent).
|
||||
type Invoke func(ctx context.Context, text string) (string, error)
|
||||
|
||||
// StreamInvoke runs an agent for one message and returns streaming output chunks.
|
||||
type StreamInvoke func(ctx context.Context, text string) (ai.Stream, error)
|
||||
|
||||
// NewAgentHandler returns an http.Handler that serves the A2A protocol
|
||||
// for a single agent: its Agent Card at / and /.well-known/agent.json,
|
||||
// and the JSON-RPC endpoint at /. invoke runs the agent. This is what an
|
||||
@@ -106,6 +112,19 @@ func NewAgentHandler(card AgentCard, invoke Invoke) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// NewAgentStreamHandler is like NewAgentHandler, but serves A2A message/stream
|
||||
// by forwarding model chunks as server-sent task updates when stream is non-nil.
|
||||
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke) http.Handler {
|
||||
d := newDispatcher()
|
||||
mux := http.NewServeMux()
|
||||
card.URL = strings.TrimRight(card.URL, "/")
|
||||
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
|
||||
mux.HandleFunc("GET /{$}", serveCard)
|
||||
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
|
||||
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serveWithStream(w, r, invoke, stream) })
|
||||
return mux
|
||||
}
|
||||
|
||||
// Serve creates a gateway and serves it on opts.Address (blocking).
|
||||
func Serve(opts Options) error {
|
||||
g := New(opts)
|
||||
@@ -206,10 +225,20 @@ type Task struct {
|
||||
Kind string `json:"kind"` // "task"
|
||||
}
|
||||
|
||||
// PushNotificationConfig tells the gateway where to POST task updates for a
|
||||
// task. The gateway stores one config per task and delivers best-effort JSON
|
||||
// task snapshots whenever that task changes.
|
||||
type PushNotificationConfig struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Authentication map[string]string `json:"authentication,omitempty"`
|
||||
}
|
||||
|
||||
// Task states (JSON-RPC binding wire values).
|
||||
const (
|
||||
stateCompleted = "completed"
|
||||
stateFailed = "failed"
|
||||
stateWorking = "working"
|
||||
)
|
||||
|
||||
// JSON-RPC envelopes.
|
||||
@@ -314,7 +343,7 @@ func Card(name, url, description string, services []string) AgentCard {
|
||||
URL: url,
|
||||
Version: "1.0.0",
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: Capabilities{Streaming: false, PushNotifications: false},
|
||||
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"},
|
||||
@@ -395,14 +424,21 @@ func (g *Gateway) handleRPC(w http.ResponseWriter, r *http.Request) {
|
||||
// retains recent tasks for tasks/get. It is shared by the gateway (one
|
||||
// per registry) and embedded agents (one per agent).
|
||||
type dispatcher struct {
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
order []string // task ids in insertion order, for bounded eviction
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
pushConfigs map[string]PushNotificationConfig
|
||||
order []string // task ids in insertion order, for bounded eviction
|
||||
}
|
||||
|
||||
func newDispatcher() *dispatcher { return &dispatcher{tasks: map[string]*Task{}} }
|
||||
func newDispatcher() *dispatcher {
|
||||
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}}
|
||||
}
|
||||
|
||||
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
|
||||
d.serveWithStream(w, r, invoke, nil)
|
||||
}
|
||||
|
||||
func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, invoke Invoke, streamInvoke StreamInvoke) {
|
||||
var req rpcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeRPC(w, nil, nil, &rpcError{Code: errParse, Message: "parse error"})
|
||||
@@ -415,14 +451,24 @@ func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke
|
||||
|
||||
switch req.Method {
|
||||
case "message/send":
|
||||
d.send(w, req, invoke)
|
||||
d.send(requestContext(r.Context()), w, req, invoke)
|
||||
case "message/stream":
|
||||
if streamInvoke != nil {
|
||||
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke)
|
||||
return
|
||||
}
|
||||
d.stream(requestContext(r.Context()), w, req, invoke)
|
||||
case "tasks/get":
|
||||
d.get(w, req)
|
||||
case "tasks/pushNotificationConfig/set":
|
||||
d.setPushConfig(w, req)
|
||||
case "tasks/pushNotificationConfig/get":
|
||||
d.getPushConfig(w, req)
|
||||
case "tasks/cancel":
|
||||
// v1 tasks complete synchronously, so they're already terminal.
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errNotCancelable, Message: "task is not cancelable"})
|
||||
case "message/stream", "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "streaming is not supported"})
|
||||
case "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "resubscribe is not supported"})
|
||||
default:
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "method not found: " + req.Method})
|
||||
}
|
||||
@@ -432,7 +478,32 @@ type sendParams struct {
|
||||
Message Message `json:"message"`
|
||||
}
|
||||
|
||||
func (d *dispatcher) send(w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
func (d *dispatcher) send(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
task, e := d.run(ctx, req.Params, invoke)
|
||||
if e != nil {
|
||||
writeRPC(w, req.ID, nil, e)
|
||||
return
|
||||
}
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
task, e := d.run(ctx, req.Params, invoke)
|
||||
if e != nil {
|
||||
writeRPC(w, req.ID, nil, e)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(sseWriter{w: w}).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
@@ -443,28 +514,73 @@ func (d *dispatcher) send(w http.ResponseWriter, req rpcRequest, invoke Invoke)
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"})
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := invoke(r2ctx(), text)
|
||||
stream, err := invoke(ctx, text)
|
||||
if err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInternal, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
taskID := uuid.New().String()
|
||||
contextID := p.Message.ContextID
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
}
|
||||
task := &Task{
|
||||
ID: uuid.New().String(),
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: []Message{p.Message},
|
||||
Status: TaskStatus{Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateCompleted, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
task := taskFromReplyWithIDs(p.Message, "error: "+err.Error(), stateFailed, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task, Error: &rpcError{Code: errInternal, Message: err.Error()}})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Invoke) (*Task, *rpcError) {
|
||||
var p sendParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid params"}
|
||||
}
|
||||
text := textOf(p.Message.Parts)
|
||||
if text == "" {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"}
|
||||
}
|
||||
|
||||
reply, err := invoke(ctx, text)
|
||||
state := stateCompleted
|
||||
if err != nil {
|
||||
task.Status.State = stateFailed
|
||||
task.Artifacts = []Artifact{textArtifact("error: " + err.Error())}
|
||||
} else {
|
||||
task.Status.State = stateCompleted
|
||||
task.Artifacts = []Artifact{textArtifact(reply)}
|
||||
reply = "error: " + err.Error()
|
||||
state = stateFailed
|
||||
}
|
||||
task := d.taskFromReply(p.Message, reply, state)
|
||||
d.store(task)
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
type getParams struct {
|
||||
@@ -487,6 +603,47 @@ func (d *dispatcher) get(w http.ResponseWriter, req rpcRequest) {
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
type pushConfigParams struct {
|
||||
ID string `json:"id"`
|
||||
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
|
||||
}
|
||||
|
||||
func (d *dispatcher) setPushConfig(w http.ResponseWriter, req rpcRequest) {
|
||||
var p pushConfigParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" || p.PushNotificationConfig.URL == "" {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
task := d.tasks[p.ID]
|
||||
if task != nil {
|
||||
d.pushConfigs[p.ID] = p.PushNotificationConfig
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if task == nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errTaskNotFound, Message: "task not found"})
|
||||
return
|
||||
}
|
||||
writeRPC(w, req.ID, map[string]any{"id": p.ID, "pushNotificationConfig": p.PushNotificationConfig}, nil)
|
||||
go d.deliverPush(p.ID, task)
|
||||
}
|
||||
|
||||
func (d *dispatcher) getPushConfig(w http.ResponseWriter, req rpcRequest) {
|
||||
var p getParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
cfg, ok := d.pushConfigs[p.ID]
|
||||
d.mu.Unlock()
|
||||
if !ok {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errTaskNotFound, Message: "push notification config not found"})
|
||||
return
|
||||
}
|
||||
writeRPC(w, req.ID, map[string]any{"id": p.ID, "pushNotificationConfig": cfg}, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent RPC
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -515,13 +672,93 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
|
||||
|
||||
func (d *dispatcher) store(t *Task) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.tasks[t.ID] = t
|
||||
d.order = append(d.order, t.ID)
|
||||
for len(d.order) > maxTasks {
|
||||
oldest := d.order[0]
|
||||
d.order = d.order[1:]
|
||||
delete(d.tasks, oldest)
|
||||
delete(d.pushConfigs, oldest)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
go d.deliverPush(t.ID, t)
|
||||
}
|
||||
|
||||
func (d *dispatcher) taskFromReply(input Message, reply, state string) *Task {
|
||||
contextID := input.ContextID
|
||||
taskID := input.TaskID
|
||||
var history []Message
|
||||
if taskID != "" {
|
||||
d.mu.Lock()
|
||||
prev := d.tasks[taskID]
|
||||
if prev != nil {
|
||||
contextID = prev.ContextID
|
||||
history = append(history, prev.History...)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
if taskID == "" {
|
||||
taskID = uuid.New().String()
|
||||
}
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
}
|
||||
return taskFromReplyWithIDsAndHistory(input, reply, state, taskID, contextID, history)
|
||||
}
|
||||
|
||||
func taskFromReplyWithIDs(input Message, reply, state, taskID, contextID string) *Task {
|
||||
return taskFromReplyWithIDsAndHistory(input, reply, state, taskID, contextID, nil)
|
||||
}
|
||||
|
||||
func taskFromReplyWithIDsAndHistory(input Message, reply, state, taskID, contextID string, history []Message) *Task {
|
||||
input.TaskID = taskID
|
||||
input.ContextID = contextID
|
||||
if input.Kind == "" {
|
||||
input.Kind = "message"
|
||||
}
|
||||
task := &Task{
|
||||
ID: taskID,
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: append(append([]Message{}, history...), input),
|
||||
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(reply)},
|
||||
}
|
||||
task.History = append(task.History, Message{
|
||||
Role: "agent",
|
||||
Parts: []Part{{Kind: "text", Text: reply}},
|
||||
MessageID: uuid.New().String(),
|
||||
TaskID: task.ID,
|
||||
ContextID: task.ContextID,
|
||||
Kind: "message",
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
func (d *dispatcher) deliverPush(taskID string, task *Task) {
|
||||
d.mu.Lock()
|
||||
cfg, ok := d.pushConfigs[taskID]
|
||||
d.mu.Unlock()
|
||||
if !ok || cfg.URL == "" || task == nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +779,47 @@ func textArtifact(text string) Artifact {
|
||||
}
|
||||
}
|
||||
|
||||
// requestContext carries request cancellation and deadlines into the downstream
|
||||
// agent call without leaking HTTP transport context values into the go-micro
|
||||
// client stack.
|
||||
func requestContext(parent context.Context) context.Context {
|
||||
if err := parent.Err(); err != nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
ctx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
if deadline, ok := parent.Deadline(); ok {
|
||||
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
go func() {
|
||||
<-parent.Done()
|
||||
cancel()
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type sseWriter struct {
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
func (s sseWriter) Write(p []byte) (int, error) {
|
||||
if _, err := s.w.Write([]byte("data: ")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := s.w.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if _, err := s.w.Write([]byte("\n")); err != nil {
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -554,7 +832,3 @@ func writeRPC(w http.ResponseWriter, id json.RawMessage, result any, e *rpcError
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: e})
|
||||
}
|
||||
|
||||
// r2ctx returns a background context for the agent call. (Kept as a seam
|
||||
// so request-scoped context/deadlines can be threaded later.)
|
||||
func r2ctx() context.Context { return context.Background() }
|
||||
|
||||
+294
-2
@@ -4,12 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
@@ -44,6 +48,7 @@ func newGatewayWithAgent(t *testing.T) (*httptest.Server, func()) {
|
||||
|
||||
srv := server.NewServer(
|
||||
server.Name("echo"),
|
||||
server.Address("127.0.0.1:0"),
|
||||
server.Registry(reg),
|
||||
server.Metadata(map[string]string{"type": "agent", "services": ""}),
|
||||
)
|
||||
@@ -101,6 +106,12 @@ func TestMessageSendAndGet(t *testing.T) {
|
||||
if len(task.Artifacts) != 1 || textOf(task.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("artifact = %+v, want text 'pong'", task.Artifacts)
|
||||
}
|
||||
if len(task.History) != 2 || task.History[1].Role != "agent" || textOf(task.History[1].Parts) != "pong" {
|
||||
t.Fatalf("history = %+v, want user turn followed by agent reply", task.History)
|
||||
}
|
||||
if task.History[1].TaskID != task.ID || task.History[1].ContextID != task.ContextID {
|
||||
t.Fatalf("agent history linkage = task %q/%q context %q/%q", task.History[1].TaskID, task.ID, task.History[1].ContextID, task.ContextID)
|
||||
}
|
||||
|
||||
got := rpcTask(t, ts.URL+"/agents/echo", `{
|
||||
"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"`+task.ID+`"}}`)
|
||||
@@ -109,6 +120,287 @@ func TestMessageSendAndGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSendContinuesExistingTask(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
first := rpcTaskFromBody(t, d, `{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"first"}]}}}`, func(_ context.Context, text string) (string, error) {
|
||||
return "reply to " + text, nil
|
||||
})
|
||||
|
||||
secondBody := fmt.Sprintf(`{
|
||||
"jsonrpc":"2.0","id":2,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m2","taskId":"%s","contextId":"%s",
|
||||
"parts":[{"kind":"text","text":"second"}]}}}`, first.ID, first.ContextID)
|
||||
second := rpcTaskFromBody(t, d, secondBody, func(_ context.Context, text string) (string, error) {
|
||||
return "reply to " + text, nil
|
||||
})
|
||||
|
||||
if second.ID != first.ID || second.ContextID != first.ContextID {
|
||||
t.Fatalf("continued task identity = %s/%s, want %s/%s", second.ID, second.ContextID, first.ID, first.ContextID)
|
||||
}
|
||||
if len(second.History) != 4 {
|
||||
t.Fatalf("continued history len = %d, want 4: %+v", len(second.History), second.History)
|
||||
}
|
||||
if textOf(second.History[0].Parts) != "first" || textOf(second.History[2].Parts) != "second" {
|
||||
t.Fatalf("continued history did not preserve turns: %+v", second.History)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, first.ID)
|
||||
if got.ID != first.ID || len(got.History) != 4 {
|
||||
t.Fatalf("stored continued task = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushNotificationConfigDeliversTaskUpdates(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
updates := make(chan Task, 2)
|
||||
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||
t.Errorf("authorization = %q, want bearer token", got)
|
||||
}
|
||||
var task Task
|
||||
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
|
||||
t.Errorf("decode push task: %v", err)
|
||||
return
|
||||
}
|
||||
updates <- task
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer push.Close()
|
||||
|
||||
task := rpcTaskFromBody(t, d, `{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"ping"}]}}}`, func(_ context.Context, text string) (string, error) {
|
||||
return "pong", nil
|
||||
})
|
||||
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tasks/pushNotificationConfig/set","params":{"id":"%s","pushNotificationConfig":{"url":"%s","token":"secret"}}}`, task.ID, push.URL)
|
||||
var setResp struct {
|
||||
Result struct {
|
||||
ID string `json:"id"`
|
||||
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
|
||||
} `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
rpcDispatcher(t, d, body, nil, &setResp)
|
||||
if setResp.Error != nil {
|
||||
t.Fatalf("set push config error: %+v", setResp.Error)
|
||||
}
|
||||
if setResp.Result.ID != task.ID || setResp.Result.PushNotificationConfig.URL != push.URL {
|
||||
t.Fatalf("set push config result = %+v", setResp.Result)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-updates:
|
||||
if got.ID != task.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("push update = %+v, want completed task", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for push update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSendUsesRequestContext(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"ping"}]}}}`))
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
d.serve(rr, req, func(ctx context.Context, text string) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "unexpected success", nil
|
||||
})
|
||||
|
||||
var resp struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Result().Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", resp.Error)
|
||||
}
|
||||
if resp.Result.Status.State != stateFailed {
|
||||
t.Fatalf("task state = %q, want failed", resp.Result.Status.State)
|
||||
}
|
||||
if len(resp.Result.Artifacts) != 1 || textOf(resp.Result.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("artifact = %+v, want context cancellation", resp.Result.Artifacts)
|
||||
}
|
||||
if len(resp.Result.History) != 2 || resp.Result.History[1].Role != "agent" || textOf(resp.Result.History[1].Parts) != "error: context canceled" {
|
||||
t.Fatalf("history = %+v, want failed agent reply recorded", resp.Result.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStream(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
resp, err := http.Post(ts.URL+"/agents/echo", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
|
||||
var line string
|
||||
if _, err := fmt.Fscan(resp.Body, &line); err != nil {
|
||||
t.Fatalf("read event prefix: %v", err)
|
||||
}
|
||||
if line != "data:" {
|
||||
t.Fatalf("event prefix = %q, want data:", line)
|
||||
}
|
||||
var out struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
if out.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", out.Error)
|
||||
}
|
||||
if out.Result.Status.State != stateCompleted || len(out.Result.Artifacts) != 1 || textOf(out.Result.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("streamed task = %+v", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
type sliceStream struct {
|
||||
chunks []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *sliceStream) Recv() (*ai.Response, error) {
|
||||
if len(s.chunks) == 0 {
|
||||
if s.err != nil {
|
||||
err := s.err
|
||||
s.err = nil
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
next := s.chunks[0]
|
||||
s.chunks = s.chunks[1:]
|
||||
return &ai.Response{Reply: next}, nil
|
||||
}
|
||||
|
||||
func (s *sliceStream) Close() error { return nil }
|
||||
|
||||
func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
d.serveWithStream(rr, req, nil, func(ctx context.Context, text string) (ai.Stream, error) {
|
||||
if text != "ping" {
|
||||
t.Fatalf("stream text = %q, want ping", text)
|
||||
}
|
||||
return &sliceStream{chunks: []string{"po", "ng"}}, nil
|
||||
})
|
||||
|
||||
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) != 3 {
|
||||
t.Fatalf("events = %d, want 3; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
for i, event := range events {
|
||||
if event.Error != nil {
|
||||
t.Fatalf("event %d error: %+v", i, event.Error)
|
||||
}
|
||||
if event.Result.ID != events[0].Result.ID || event.Result.ContextID != events[0].Result.ContextID {
|
||||
t.Fatalf("event %d changed task identity: %+v vs %+v", i, event.Result, events[0].Result)
|
||||
}
|
||||
}
|
||||
if events[0].Result.Status.State != stateWorking || textOf(events[0].Result.Artifacts[0].Parts) != "po" {
|
||||
t.Fatalf("first event = %+v, want working po", events[0].Result)
|
||||
}
|
||||
final := events[len(events)-1].Result
|
||||
if final.Status.State != stateCompleted || textOf(final.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("final event = %+v, want completed pong", final)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, final.ID)
|
||||
if got.ID != final.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("stored task = %+v, want final", got)
|
||||
}
|
||||
}
|
||||
|
||||
func rpcTaskFromDispatcher(t *testing.T, d *dispatcher, id string) Task {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"%s"}}`, id)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
d.serve(rr, req, nil)
|
||||
var resp struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Result().Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode tasks/get: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("tasks/get error: %+v", resp.Error)
|
||||
}
|
||||
return resp.Result
|
||||
}
|
||||
|
||||
func rpcTaskFromBody(t *testing.T, d *dispatcher, body string, invoke Invoke) Task {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
rpcDispatcher(t, d, body, invoke, &resp)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", resp.Error)
|
||||
}
|
||||
return resp.Result
|
||||
}
|
||||
|
||||
func rpcDispatcher(t *testing.T, d *dispatcher, body string, invoke Invoke, v any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
d.serve(rr, req, invoke)
|
||||
if err := json.NewDecoder(rr.Result().Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode dispatcher response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownMethod(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
@@ -116,9 +408,9 @@ func TestUnknownMethod(t *testing.T) {
|
||||
var resp struct {
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{}}`, &resp)
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"tasks/resubscribe","params":{}}`, &resp)
|
||||
if resp.Error == nil || resp.Error.Code != errMethodNotFound {
|
||||
t.Errorf("expected method-not-found for streaming, got %+v", resp.Error)
|
||||
t.Errorf("expected method-not-found for resubscribe, got %+v", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+62
-11
@@ -60,15 +60,38 @@ func (c *Client) Card(ctx context.Context) (*AgentCard, error) {
|
||||
// If the agent returns a task that isn't yet terminal, Send polls
|
||||
// tasks/get until it completes or ctx is done.
|
||||
func (c *Client) Send(ctx context.Context, text string) (string, error) {
|
||||
res, err := c.call(ctx, "message/send", sendParams{Message: Message{
|
||||
task, err := c.SendMessage(ctx, Message{
|
||||
Role: "user",
|
||||
Kind: "message",
|
||||
MessageID: uuid.New().String(),
|
||||
Parts: []Part{{Kind: "text", Text: text}},
|
||||
}})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if task.Status.State != stateCompleted {
|
||||
return "", fmt.Errorf("remote task %s ended in state %q", task.ID, task.Status.State)
|
||||
}
|
||||
return artifactsText(task.Artifacts), nil
|
||||
}
|
||||
|
||||
// SendMessage sends an A2A message and returns the resulting terminal task.
|
||||
// To continue a multi-turn task, pass a Message with TaskID and ContextID set
|
||||
// to a prior task's id and context id.
|
||||
func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error) {
|
||||
if message.MessageID == "" {
|
||||
message.MessageID = uuid.New().String()
|
||||
}
|
||||
if message.Kind == "" {
|
||||
message.Kind = "message"
|
||||
}
|
||||
if message.Role == "" {
|
||||
message.Role = "user"
|
||||
}
|
||||
res, err := c.call(ctx, "message/send", sendParams{Message: message})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The result is a Message or a Task; the "kind" field disambiguates.
|
||||
var probe struct {
|
||||
@@ -79,33 +102,61 @@ func (c *Client) Send(ctx context.Context, text string) (string, error) {
|
||||
if probe.Kind == "message" {
|
||||
var m Message
|
||||
if err := json.Unmarshal(res, &m); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return textOf(m.Parts), nil
|
||||
return &Task{
|
||||
ID: m.TaskID,
|
||||
ContextID: m.ContextID,
|
||||
Kind: "task",
|
||||
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
|
||||
History: []Message{m},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var task Task
|
||||
if err := json.Unmarshal(res, &task); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
for !terminal(task.Status.State) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
got, err := c.call(ctx, "tasks/get", getParams{ID: task.ID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(got, &task); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if task.Status.State != stateCompleted {
|
||||
return "", fmt.Errorf("remote task %s ended in state %q", task.ID, task.Status.State)
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// 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{
|
||||
ID: taskID,
|
||||
PushNotificationConfig: cfg,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// PushNotificationConfig returns the remote push notification config for taskID.
|
||||
func (c *Client) PushNotificationConfig(ctx context.Context, taskID string) (PushNotificationConfig, error) {
|
||||
res, err := c.call(ctx, "tasks/pushNotificationConfig/get", getParams{ID: taskID})
|
||||
if err != nil {
|
||||
return PushNotificationConfig{}, err
|
||||
}
|
||||
return artifactsText(task.Artifacts), nil
|
||||
var out struct {
|
||||
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return PushNotificationConfig{}, err
|
||||
}
|
||||
return out.PushNotificationConfig, nil
|
||||
}
|
||||
|
||||
// call performs one JSON-RPC request and returns the raw result.
|
||||
|
||||
@@ -2,8 +2,11 @@ package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An agent that embeds NewAgentHandler is directly A2A-queryable — no
|
||||
@@ -48,3 +51,62 @@ func TestClientSendAndCard(t *testing.T) {
|
||||
t.Errorf("Send reply = %q, want pong", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
|
||||
card := Card("solo", "http://localhost:4000", "", []string{"task"})
|
||||
h := NewAgentHandler(card, func(_ context.Context, text string) (string, error) {
|
||||
return "echo:" + text, nil
|
||||
})
|
||||
ts := httptest.NewServer(h)
|
||||
defer ts.Close()
|
||||
|
||||
updates := make(chan Task, 1)
|
||||
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var task Task
|
||||
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
|
||||
t.Errorf("decode push task: %v", err)
|
||||
return
|
||||
}
|
||||
updates <- task
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer push.Close()
|
||||
|
||||
cl := NewClient(ts.URL)
|
||||
first, err := cl.SendMessage(context.Background(), Message{
|
||||
Parts: []Part{{Kind: "text", Text: "one"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first SendMessage: %v", err)
|
||||
}
|
||||
second, err := cl.SendMessage(context.Background(), Message{
|
||||
TaskID: first.ID,
|
||||
ContextID: first.ContextID,
|
||||
Parts: []Part{{Kind: "text", Text: "two"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second SendMessage: %v", err)
|
||||
}
|
||||
if second.ID != first.ID || second.ContextID != first.ContextID || len(second.History) != 4 {
|
||||
t.Fatalf("continued task = %+v, first %+v", second, first)
|
||||
}
|
||||
cfg := PushNotificationConfig{URL: push.URL}
|
||||
if err := cl.SetPushNotificationConfig(context.Background(), second.ID, cfg); err != nil {
|
||||
t.Fatalf("SetPushNotificationConfig: %v", err)
|
||||
}
|
||||
got, err := cl.PushNotificationConfig(context.Background(), second.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("PushNotificationConfig: %v", err)
|
||||
}
|
||||
if got.URL != push.URL {
|
||||
t.Fatalf("PushNotificationConfig URL = %q, want %q", got.URL, push.URL)
|
||||
}
|
||||
select {
|
||||
case update := <-updates:
|
||||
if update.ID != second.ID {
|
||||
t.Fatalf("push update ID = %q, want %q", update.ID, second.ID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for push update")
|
||||
}
|
||||
}
|
||||
|
||||
+51
-2
@@ -314,7 +314,8 @@ func DNSCheck(host string) CheckFunc {
|
||||
//
|
||||
// The lookup runs under the check's timeout, so an unreachable registry
|
||||
// (for example an etcd that has gone away) is reported as down rather
|
||||
// than blocking the probe.
|
||||
// than blocking the probe. Registries that honor ListContext also receive
|
||||
// the check context directly.
|
||||
func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if reg == nil {
|
||||
@@ -322,7 +323,7 @@ func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reg.ListServices()
|
||||
_, err := reg.ListServices(registry.ListContext(ctx))
|
||||
errc <- err
|
||||
}()
|
||||
select {
|
||||
@@ -337,6 +338,54 @@ func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RegistryServiceCheck creates a check that verifies the local service
|
||||
// registration is visible in the registry. RegistryCheck only proves the
|
||||
// registry is reachable; this check also catches the common failure mode
|
||||
// where the process is alive and the registry answers, but this service's
|
||||
// node lease/record has disappeared and other services can no longer
|
||||
// discover it.
|
||||
//
|
||||
// Pass the service name and node ID used during registration. The default
|
||||
// RPC server node ID is service.Options().Name + "-" + service.Options().Id.
|
||||
func RegistryServiceCheck(reg registry.Registry, serviceName, nodeID string) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if reg == nil {
|
||||
return errors.New("no registry configured")
|
||||
}
|
||||
if serviceName == "" {
|
||||
return errors.New("no service name configured")
|
||||
}
|
||||
if nodeID == "" {
|
||||
return errors.New("no service node configured")
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
services, err := reg.GetService(serviceName, registry.GetContext(ctx))
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("registry %s lookup %s failed: %w", reg.String(), serviceName, err)
|
||||
return
|
||||
}
|
||||
for _, service := range services {
|
||||
for _, node := range service.Nodes {
|
||||
if node.Id == nodeID {
|
||||
errc <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
errc <- fmt.Errorf("registry %s missing node %s for service %s", reg.String(), nodeID, serviceName)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("registry %s service check timed out: %w", reg.String(), ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CustomCheck creates a check from any function returning an error
|
||||
func CustomCheck(fn func() error) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
|
||||
@@ -89,3 +89,60 @@ func TestRegistryCheckMarksNotReady(t *testing.T) {
|
||||
t.Error("service should be not-ready when the registry check is down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckHealthy(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
service := ®istry.Service{
|
||||
Name: "orders",
|
||||
Nodes: []*registry.Node{{Id: "orders-1"}},
|
||||
}
|
||||
if err := reg.Register(service); err != nil {
|
||||
t.Fatalf("register service: %v", err)
|
||||
}
|
||||
|
||||
check := RegistryServiceCheck(reg, "orders", "orders-1")
|
||||
if err := check(context.Background()); err != nil {
|
||||
t.Fatalf("registered service node should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMissingNode(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
service := ®istry.Service{
|
||||
Name: "orders",
|
||||
Nodes: []*registry.Node{{Id: "orders-1"}},
|
||||
}
|
||||
if err := reg.Register(service); err != nil {
|
||||
t.Fatalf("register service: %v", err)
|
||||
}
|
||||
|
||||
check := RegistryServiceCheck(reg, "orders", "orders-2")
|
||||
err := check(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("missing service node should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing node orders-2") {
|
||||
t.Errorf("error should describe the missing node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMissingService(t *testing.T) {
|
||||
check := RegistryServiceCheck(registry.NewMemoryRegistry(), "orders", "orders-1")
|
||||
err := check(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("missing service should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), registry.ErrNotFound.Error()) {
|
||||
t.Errorf("error should include registry lookup failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMarksNotReady(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register("registry-service", RegistryServiceCheck(registry.NewMemoryRegistry(), "orders", "orders-1"))
|
||||
if IsReady(context.Background()) {
|
||||
t.Error("service should be not-ready when its registry node is missing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent provider conformance matrix
|
||||
|
||||
`go test ./...` includes `TestAgentProviderConformanceMatrix`, a shared agent
|
||||
scenario that runs against every registered chat provider. The scenario asks an
|
||||
agent to call a deterministic local tool, verifies the tool receives `ai.RunInfo`,
|
||||
and checks the final response carries the conformance marker. A fake provider path
|
||||
runs on every machine without network access so CI always exercises the harness.
|
||||
|
||||
Live providers are opt-in to avoid flaky unauthenticated PR checks and accidental
|
||||
API spend. To run the live matrix, set `GO_MICRO_AGENT_CONFORMANCE_LIVE=1` plus the
|
||||
provider API keys you want to exercise:
|
||||
|
||||
| Provider | Required API key | Optional model override |
|
||||
| --- | --- | --- |
|
||||
| OpenAI | `OPENAI_API_KEY` | `GO_MICRO_CONFORMANCE_OPENAI_MODEL` |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL` |
|
||||
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
|
||||
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
|
||||
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
|
||||
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
|
||||
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
|
||||
|
||||
When `GO_MICRO_AGENT_CONFORMANCE_LIVE` or a provider key is absent, the live
|
||||
provider subtest reports a deterministic skip. When both are present, a provider
|
||||
failure is a real test failure because drift in chat, tool calling, run metadata,
|
||||
or final-answer behavior means the services → agents lifecycle is no longer
|
||||
consistent across providers.
|
||||
|
||||
The companion `TestAgentProviderConformanceFakeError` keeps provider error
|
||||
propagation covered locally without relying on external credentials.
|
||||
@@ -69,11 +69,90 @@ Grounded in real signal, never speculative rewrites. Each cycle draws from:
|
||||
recurring jobs expire after 7 days, so it is **not** a durable scheduler.
|
||||
- **GitHub Actions (durable)** — a scheduled workflow that runs the loop
|
||||
independently of any session. This is the real backbone; it opens a fresh
|
||||
tracking issue for each increment and dispatches Codex there, so every run gets
|
||||
a unique Codex-derived branch and a PR that closes its tracking issue. It needs
|
||||
a `CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
tracking issue for each increment and dispatches Codex there. It needs a
|
||||
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
without that secret the workflow deliberately no-ops to avoid ignored bot
|
||||
comments. See `.github/workflows/continuous-improvement.yml`.
|
||||
comments. See `.github/workflows/continuous-improvement.yml` and the mechanics
|
||||
below.
|
||||
|
||||
## How the durable loop works (mechanics)
|
||||
|
||||
Hard-won wiring — change any one piece and the loop silently stops producing
|
||||
merged PRs. Each scheduled run:
|
||||
|
||||
1. **Opens a fresh issue per increment** (`Continuous improvement increment #N`)
|
||||
and posts the `@codex` instruction on it. *Why a fresh issue:* Codex derives
|
||||
its branch name from the triggering issue's context, so re-using one tracker
|
||||
issue collapses every run onto one branch name and only the first PR opens —
|
||||
the rest collide and silently fail.
|
||||
2. **Posts as a user, not the Actions bot.** Codex ignores `@codex` comments
|
||||
authored by `github-actions[bot]`, so the dispatch uses `CODEX_TRIGGER_TOKEN`
|
||||
(a PAT for a user account Codex follows). No token → the step no-ops.
|
||||
3. **Codex opens the PR itself with `gh` — never `make_pr`.** In the Codex Cloud
|
||||
sandbox the `make_pr` tool is a **no-op stub**: it records the PR title/body
|
||||
for the manual "Create PR" button and never pushes a branch or calls the API.
|
||||
So the dispatch and [`AGENTS.md`](../../AGENTS.md) tell Codex to do it by hand:
|
||||
|
||||
```sh
|
||||
git switch -c codex/increment-<issue> # unique branch, codex/ prefix
|
||||
git push -u origin codex/increment-<issue>
|
||||
gh pr create --base master --label codex --title "…" --body "… Closes #<issue>"
|
||||
gh pr merge --squash --auto --delete-branch
|
||||
```
|
||||
|
||||
This requires the Codex setup script to install `gh` and run `gh auth
|
||||
setup-git` (so `git push` is authenticated) with a write-scoped token.
|
||||
4. **Merges via GitHub native auto-merge, gated by branch protection.** `master`
|
||||
requires the CI status checks (build, tests, golangci-lint) and **0 approving
|
||||
reviews**. `gh pr merge --auto` enables auto-merge; GitHub lands the PR the
|
||||
moment checks pass and deletes the branch. `Closes #<issue>` auto-closes the
|
||||
tracking issue. There is **no merge sweep workflow** — branch protection is
|
||||
the gate.
|
||||
|
||||
### Do-not-break list
|
||||
|
||||
- **Don't re-add required approvals** to `master` — it blocks every autonomous
|
||||
merge. The intended gate is **green CI only**.
|
||||
- **Don't point the dispatch at one standing tracker issue** — one issue per run.
|
||||
- **Don't tell Codex to use `make_pr`** (or imply a token "isn't a substitute"):
|
||||
it cannot open a PR. `gh` is the only path.
|
||||
- **Don't manually re-implement a Codex increment during the summary→PR lag**
|
||||
(Codex posts an optimistic "opened a PR" comment ~30–45 min before the PR
|
||||
actually appears). Re-doing it creates duplicate PRs and stale branches that
|
||||
then block the next run. Wait for the PR, or let it ride.
|
||||
|
||||
## Overseer passes (DevRel + Architect)
|
||||
|
||||
The hourly loop ships increments; two periodic passes keep the *whole* heading in
|
||||
the right direction. Both use the same mechanism (fresh issue → `@codex` →
|
||||
output) but produce direction and coherence, not just code.
|
||||
|
||||
- **DevRel — daily** (`.github/workflows/devrel-review.yml`). Audits the public
|
||||
surface (README, website landing + docs, blog) for coherence with the North
|
||||
Star, README crispness, and blog-worthy material. **Autonomy boundary:** safe
|
||||
factual-alignment and crispness fixes auto-merge like any increment;
|
||||
brand/positioning copy and blog drafts are *surfaced in a report* for the
|
||||
human, never auto-merged.
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/architecture-review.yml`).
|
||||
The *founder lens*, running alongside the builders. Each run it **tracks live
|
||||
state** (what just merged, what's in flight), **prioritizes the roadmap**
|
||||
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
|
||||
coherence and seams, dev-UX friction, missing pieces, drift/realignment), and
|
||||
**maintains the ranked queue** in [`PRIORITIES.md`](PRIORITIES.md) — re-ranking
|
||||
to reflect reality, backing each top item with a scoped issue, and posting an
|
||||
assessment. It runs at `:59`, just before the `:29` increment, so it
|
||||
re-prioritizes and *then* the loop builds the new top. **Its output is the
|
||||
prioritized queue plus the assessment** — it does **not** make breaking or
|
||||
architectural changes itself (those stay with the human). To avoid churn it only
|
||||
opens a PR when the ranking actually changes.
|
||||
|
||||
The two loops are coupled through `PRIORITIES.md`: the **architect decides *what***
|
||||
(roadmap + internal priorities, ranked, issue-linked) and the **hourly increment
|
||||
loop builds the top open item** — falling back to its own judgment only if the
|
||||
queue is empty. DevRel keeps the public story honest alongside. So work is
|
||||
roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in each
|
||||
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
|
||||
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Priorities
|
||||
|
||||
The ranked work queue for the autonomous improvement loop. The
|
||||
**architecture-review** pass (the *architect*) owns this file: each run it turns
|
||||
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
|
||||
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
|
||||
DX friction) into a single ordered list — highest-value first — and links each
|
||||
item to a tracking issue. The hourly **continuous-improvement** pass works the
|
||||
**top item whose issue is still open**. So the architect decides *what*, and the
|
||||
increment loop *builds* it.
|
||||
|
||||
**Reading / editing.** An item is done when its linked issue closes (the increment
|
||||
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
|
||||
primary ordering; internal findings are interleaved by value, not kept in a
|
||||
separate list. The human can reorder this list — or the issues — at any time to
|
||||
redirect the loop; direction always wins.
|
||||
|
||||
**Off-limits to the loop** (the architect proposes these as notes, never as queue
|
||||
items the loop can auto-merge): brand/positioning copy, breaking public-API
|
||||
changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Later (ranked)
|
||||
|
||||
No open queued items. The previous top item, **A2A push notifications and
|
||||
multi-turn task support** (#3212), is closed; the next architecture-review pass
|
||||
should seed a fresh issue-backed item from the roadmap and improvement radar.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
@@ -4,6 +4,39 @@ This is the North Star for the project and for the autonomous improvement loop
|
||||
(see `CONTINUOUS_IMPROVEMENT.md`). Every change should move toward it; work that
|
||||
doesn't isn't an improvement, however clean.
|
||||
|
||||
## Mission — the problem we solve
|
||||
|
||||
Go Micro started in 2015 because building distributed systems in Go was too hard:
|
||||
too much boilerplate, too many decisions before a single endpoint runs. The
|
||||
mission was to **make building distributed systems simple** — sane defaults,
|
||||
pluggable, out of the developer's way.
|
||||
|
||||
Agents are distributed systems too. The moment an agent discovers services, calls
|
||||
them, holds state, and recovers from failure, it *is* a distributed system — the
|
||||
exact problem Go Micro already solved for services. So the mission hasn't
|
||||
changed, only extended:
|
||||
|
||||
> **Make building agentic, distributed software in Go simple — make building an
|
||||
> agent as easy as building a service, on one runtime, because an agent is a
|
||||
> distributed system.**
|
||||
|
||||
That is the problem we solve, and it is the question every priority is judged
|
||||
against: *does this make the services → agents → workflows lifecycle simpler, more
|
||||
cohesive, and more operable — or is it scope that doesn't serve that?* It is
|
||||
evolution, not a pivot: the decade of services work is the foundation, and the
|
||||
agent layer is that foundation leveraged for the AI era.
|
||||
|
||||
## The canon
|
||||
|
||||
The vision isn't only in this file. The years of focus and context live in the
|
||||
**corpus** — the [blog](../website/blog/) (the actual thinking, e.g. `/blog/14`
|
||||
"Going All In on AI" and `/blog/27` "Back from the Dead"), the
|
||||
[`README`](../../README.md), and the [website](../website/). Those are the canon;
|
||||
this North Star is their **distillation** and must stay faithful to them. When the
|
||||
two diverge, that's a signal — either the work has drifted from the mission, or the
|
||||
North Star has drifted from the lived story and needs re-grounding in the corpus.
|
||||
The architect re-derives alignment from the canon, not from this file alone.
|
||||
|
||||
## Thesis
|
||||
|
||||
Go Micro is an **agent harness and service framework** — one runtime that, holistically,
|
||||
|
||||
@@ -207,18 +207,19 @@ func main() {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Registry(reg), service.Client(cl))
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
ws.Handle(wsSvc)
|
||||
go ws.Run()
|
||||
|
||||
ntSvc := new(NotifyService)
|
||||
nt := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
nt.Handle(ntSvc)
|
||||
go nt.Run()
|
||||
|
||||
// The onboarder agent, registered so the flow can reach it over RPC.
|
||||
onboarder := agent.New(
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
|
||||
agent.Provider(*provider),
|
||||
|
||||
@@ -36,14 +36,14 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Registry(reg), service.Client(cl))
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := ws.Handle(wsSvc); err != nil {
|
||||
t.Fatalf("handle workspace: %v", err)
|
||||
}
|
||||
go ws.Run()
|
||||
|
||||
ntSvc := new(NotifyService)
|
||||
nt := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := nt.Handle(ntSvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
|
||||
|
||||
onboarder := agent.New(
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
|
||||
agent.Provider("mock"),
|
||||
|
||||
@@ -23,8 +23,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,70 +225,118 @@ func providerKey(provider string) string {
|
||||
return os.Getenv(env)
|
||||
}
|
||||
|
||||
func runPlanDelegate(provider string) error {
|
||||
apiKey := ""
|
||||
if provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else {
|
||||
apiKey = providerKey(provider)
|
||||
if apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mPlan & Delegate — live integration harness (provider: %s)\033[0m\n", provider)
|
||||
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
// Real services.
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
return fmt.Errorf("task handle: %w", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
return fmt.Errorf("notify handle: %w", err)
|
||||
}
|
||||
go notify.Run()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
go comms.Run()
|
||||
defer comms.Stop()
|
||||
|
||||
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
go conductor.Run()
|
||||
defer conductor.Stop()
|
||||
|
||||
fmt.Println("waiting for services + agents to register...")
|
||||
waitForService := func(name string) error {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("service %q never registered", name)
|
||||
}
|
||||
for _, name := range []string{"task", "notify", "comms", "conductor"} {
|
||||
if err := waitForService(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("\n\033[1m> flow:\033[0m services + agents + workflow + plan/delegate, no API key.\n\n")
|
||||
if err := f.Execute(context.Background(), "launch readiness"); err != nil {
|
||||
return fmt.Errorf("flow execute: %w", err)
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rs[len(rs)-1].Reply)
|
||||
}
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
if recs, _ := store.Scope(mem, "agent", "conductor").Read("plan"); len(recs) > 0 {
|
||||
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
|
||||
} else {
|
||||
return fmt.Errorf("plan was not persisted")
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
|
||||
}
|
||||
|
||||
fmt.Println("\n\033[32m✓ 0→hero flow complete (services → agents → workflow)\033[0m")
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
apiKey := ""
|
||||
if *provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else {
|
||||
apiKey = providerKey(*provider)
|
||||
if apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mPlan & Delegate — live integration harness (provider: %s)\033[0m\n", *provider)
|
||||
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
|
||||
|
||||
// Real services.
|
||||
task := micro.NewService("task")
|
||||
task.Handle(new(TaskService))
|
||||
go task.Run()
|
||||
|
||||
notify := micro.NewService("notify")
|
||||
notify.Handle(new(NotifyService))
|
||||
go notify.Run()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := micro.NewAgent("comms",
|
||||
micro.AgentServices("notify"),
|
||||
micro.AgentPrompt("You handle outbound notifications. Use the notify service."),
|
||||
micro.AgentProvider(*provider),
|
||||
micro.AgentAPIKey(apiKey),
|
||||
)
|
||||
go comms.Run()
|
||||
|
||||
// Real conductor agent (owns task).
|
||||
conductor := micro.NewAgent("conductor",
|
||||
micro.AgentServices("task"),
|
||||
micro.AgentPrompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
micro.AgentProvider(*provider),
|
||||
micro.AgentAPIKey(apiKey),
|
||||
)
|
||||
|
||||
fmt.Println("waiting for services + comms agent to register...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
fmt.Print("\n\033[1m> prompt:\033[0m Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified.\n\n")
|
||||
|
||||
resp, err := conductor.Ask(context.Background(),
|
||||
"Create three launch tasks: Design, Build, and Ship. Then make sure owner@acme.com is notified that the launch plan is ready.")
|
||||
if err != nil {
|
||||
if err := runPlanDelegate(*provider); err != nil {
|
||||
fmt.Println("\033[31merror:\033[0m", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", resp.Reply)
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
if recs, _ := conductor.Options().Store.Read("agent/conductor/plan"); len(recs) > 0 {
|
||||
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
|
||||
} else {
|
||||
fmt.Println("\n\033[31m! plan was not persisted\033[0m")
|
||||
}
|
||||
|
||||
fmt.Println("\n\033[32m✓ end-to-end flow complete\033[0m")
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
|
||||
// Real services on the shared registry/client.
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Registry(reg), service.Client(cl))
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Provider("mock"),
|
||||
@@ -80,6 +81,7 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
// Real conductor agent (owns task), driven programmatically.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
|
||||
agent.Provider("mock"),
|
||||
@@ -128,14 +130,14 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Registry(reg), service.Client(cl))
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -143,6 +145,7 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Provider("mock"),
|
||||
@@ -157,6 +160,7 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
// so the flow can reach it over RPC.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
|
||||
agent.Provider("mock"),
|
||||
@@ -202,3 +206,16 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
t.Errorf("notify called %d times, want 1 (flow->agent->delegate->comms chain broken)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroToHeroContract locks the roadmap's second golden path into the
|
||||
// ordinary Go test contract. It runs the same executable harness used by
|
||||
// `make harness`: services + agents + flow + plan/delegate, with only the
|
||||
// LLM replaced by the deterministic mock provider.
|
||||
func TestZeroToHeroContract(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock"); err != nil {
|
||||
t.Fatalf("0→hero harness: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,24 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
var providerEnv = map[string]string{
|
||||
@@ -37,16 +48,44 @@ func main() {
|
||||
providersFlag := flag.String("providers", "anthropic,openai,gemini,groq,mistral,together,atlascloud", "comma-separated providers to check; use mock for deterministic local checks")
|
||||
harnessesFlag := flag.String("harnesses", "universe,agent-flow,plan-delegate", "comma-separated harness names under internal/harness")
|
||||
timeoutFlag := flag.Duration("timeout", 10*time.Minute, "timeout per provider/harness run")
|
||||
requireConfiguredFlag := flag.Bool("require-configured", false, "fail when a selected live provider is missing an API key")
|
||||
capabilitiesFlag := flag.Bool("capabilities", true, "print the registered provider capability matrix before running conformance")
|
||||
summaryJSONFlag := flag.String("summary-json", "", "write a machine-readable conformance summary to this path")
|
||||
summaryMarkdownFlag := flag.String("summary-markdown", "", "write a human-readable conformance summary to this path")
|
||||
capabilityMarkdownFlag := flag.String("capabilities-markdown", "", "write the registered provider capability matrix as a Markdown table")
|
||||
flag.Parse()
|
||||
|
||||
providers := splitCSV(*providersFlag)
|
||||
harnesses := splitCSV(*harnessesFlag)
|
||||
if err := validateSelection(providers, harnesses); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *capabilitiesFlag {
|
||||
printCapabilityMatrix()
|
||||
}
|
||||
if *capabilityMarkdownFlag != "" {
|
||||
if err := writeCapabilityMarkdown(*capabilityMarkdownFlag, ai.CapabilityRows()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write capabilities markdown: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var ran, skipped, failed int
|
||||
var results []conformanceResult
|
||||
for _, provider := range providers {
|
||||
if provider != "mock" && providerKey(provider) == "" {
|
||||
fmt.Printf("- %s: skipped (set MICRO_AI_API_KEY or %s)\n", provider, providerEnv[provider])
|
||||
skipped++
|
||||
msg := fmt.Sprintf("set MICRO_AI_API_KEY or %s", providerEnv[provider])
|
||||
if *requireConfiguredFlag {
|
||||
fmt.Printf("FAIL %s: missing API key (%s)\n", provider, msg)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Status: statusFailed, Error: "missing API key: " + msg})
|
||||
} else {
|
||||
fmt.Printf("- %s: skipped (%s)\n", provider, msg)
|
||||
skipped++
|
||||
results = append(results, conformanceResult{Provider: provider, Status: statusSkipped, Error: msg})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -55,18 +94,198 @@ func main() {
|
||||
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
|
||||
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nprovider conformance: %d passed, %d skipped providers, %d failed\n", ran, skipped, failed)
|
||||
summary := conformanceSummary{
|
||||
Providers: providers,
|
||||
Harnesses: harnesses,
|
||||
Capabilities: ai.CapabilityRows(),
|
||||
Results: results,
|
||||
Passed: ran,
|
||||
Skipped: skipped,
|
||||
Failed: failed,
|
||||
}
|
||||
if *summaryJSONFlag != "" {
|
||||
if err := writeSummaryJSON(*summaryJSONFlag, summary); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write summary: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if *summaryMarkdownFlag != "" {
|
||||
if err := writeSummaryMarkdown(*summaryMarkdownFlag, summary); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write summary markdown: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
statusPassed = "passed"
|
||||
statusSkipped = "skipped"
|
||||
statusFailed = "failed"
|
||||
)
|
||||
|
||||
type conformanceResult struct {
|
||||
Provider string `json:"provider"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type conformanceSummary struct {
|
||||
Providers []string `json:"providers"`
|
||||
Harnesses []string `json:"harnesses"`
|
||||
Capabilities []ai.CapabilityRow `json:"capabilities"`
|
||||
Results []conformanceResult `json:"results"`
|
||||
Passed int `json:"passed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
func writeSummaryJSON(path string, summary conformanceSummary) error {
|
||||
b, err := json.MarshalIndent(summary, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func writeCapabilityMarkdown(path string, rows []ai.CapabilityRow) error {
|
||||
return os.WriteFile(path, []byte(capabilityMarkdown(rows)), 0o644)
|
||||
}
|
||||
|
||||
func writeSummaryMarkdown(path string, summary conformanceSummary) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Provider conformance summary\n\n")
|
||||
fmt.Fprintf(&b, "Passed: %d. Skipped providers: %d. Failed: %d.\n\n", summary.Passed, summary.Skipped, summary.Failed)
|
||||
b.WriteString("## Capability matrix\n\n")
|
||||
b.WriteString(capabilityMarkdown(summary.Capabilities))
|
||||
b.WriteString("\n## Harness results\n\n")
|
||||
b.WriteString("| Provider | Harness | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- |\n")
|
||||
for _, result := range summary.Results {
|
||||
harness := result.Harness
|
||||
if harness == "" {
|
||||
harness = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func capabilityMarkdown(rows []ai.CapabilityRow) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("| Provider | Model | Image | Video | Streaming |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func markdownCell(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||
return s
|
||||
}
|
||||
|
||||
func mark(ok bool) string {
|
||||
if ok {
|
||||
return "✅"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func printCapabilityMatrix() {
|
||||
fmt.Println("Provider capability matrix:")
|
||||
fmt.Println("provider model image video stream")
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
fmt.Printf("%-12s %-5s %-5s %-5s %-6s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func yesNo(ok bool) string {
|
||||
if ok {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func validateSelection(providers, harnesses []string) error {
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("no providers selected")
|
||||
}
|
||||
if len(harnesses) == 0 {
|
||||
return fmt.Errorf("no harnesses selected")
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
if provider == "mock" {
|
||||
continue
|
||||
}
|
||||
if _, ok := providerEnv[provider]; !ok {
|
||||
return fmt.Errorf("unknown provider %q (known: %s)", provider, knownProviders())
|
||||
}
|
||||
}
|
||||
|
||||
for _, harness := range harnesses {
|
||||
if strings.Contains(harness, string(os.PathSeparator)) || harness == "." || harness == ".." {
|
||||
return fmt.Errorf("invalid harness name %q", harness)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(repoRoot(), "internal", "harness", harness))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unknown harness %q: %w", harness, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("harness %q is not a directory", harness)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func repoRoot() string {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
|
||||
return wd
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "."
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func knownProviders() string {
|
||||
providers := make([]string, 0, len(providerEnv)+1)
|
||||
providers = append(providers, "mock")
|
||||
for provider := range providerEnv {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
slices.Sort(providers)
|
||||
return strings.Join(providers, ",")
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
@@ -120,6 +339,7 @@ func runHarness(provider, harness string, timeout time.Duration) error {
|
||||
binPath := filepath.Join(binDir, harness)
|
||||
|
||||
build := exec.CommandContext(ctx, "go", "build", "-o", binPath, "./internal/harness/"+harness)
|
||||
build.Dir = repoRoot()
|
||||
build.Stdout = os.Stdout
|
||||
build.Stderr = os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestValidateSelectionAcceptsKnownProviderAndHarness(t *testing.T) {
|
||||
if err := validateSelection([]string{"mock"}, []string{"provider-conformance"}); err != nil {
|
||||
t.Fatalf("validateSelection returned error for known selection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSelectionRejectsUnknownProvider(t *testing.T) {
|
||||
err := validateSelection([]string{"not-a-provider"}, []string{"provider-conformance"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSelection returned nil for unknown provider")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown provider "not-a-provider"`) {
|
||||
t.Fatalf("validateSelection error = %q, want unknown provider message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
|
||||
err := validateSelection([]string{"mock"}, []string{"../agent-flow"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSelection returned nil for unsafe harness name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `invalid harness name "../agent-flow"`) {
|
||||
t.Fatalf("validateSelection error = %q, want invalid harness message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
rows := ai.CapabilityRows()
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("CapabilityRows returned no providers")
|
||||
}
|
||||
|
||||
var foundOpenAI bool
|
||||
for _, row := range rows {
|
||||
if row.Provider == "openai" {
|
||||
foundOpenAI = true
|
||||
if !row.Model || !row.Image || row.Video {
|
||||
t.Fatalf("openai capabilities = %#v, want model+image only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOpenAI {
|
||||
t.Fatalf("CapabilityRows = %#v, want openai row", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCapabilityMarkdown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "capabilities.md")
|
||||
rows := []ai.CapabilityRow{
|
||||
{Provider: "mock", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "vision", Capabilities: ai.Capabilities{Image: true, Video: true}},
|
||||
}
|
||||
if err := writeCapabilityMarkdown(path, rows); err != nil {
|
||||
t.Fatalf("writeCapabilityMarkdown returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read capabilities markdown: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
"| Provider | Model | Image | Video | Streaming |",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| vision | — | ✅ | ✅ | — |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("capabilities markdown = %q, want row %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSummaryMarkdown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.md")
|
||||
summary := conformanceSummary{
|
||||
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
|
||||
Results: []conformanceResult{
|
||||
{Provider: "mock", Harness: "agent-flow", Status: statusPassed},
|
||||
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
|
||||
},
|
||||
Passed: 1,
|
||||
Skipped: 1,
|
||||
}
|
||||
if err := writeSummaryMarkdown(path, summary); err != nil {
|
||||
t.Fatalf("writeSummaryMarkdown returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read summary markdown: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
"# Provider conformance summary",
|
||||
"Passed: 1. Skipped providers: 1. Failed: 0.",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| mock | agent-flow | passed | — |",
|
||||
"| live | — | skipped | missing \\| key |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("summary markdown = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSummaryJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.json")
|
||||
summary := conformanceSummary{
|
||||
Providers: []string{"mock"},
|
||||
Harnesses: []string{"provider-conformance"},
|
||||
Results: []conformanceResult{{
|
||||
Provider: "mock",
|
||||
Harness: "provider-conformance",
|
||||
Status: statusPassed,
|
||||
}},
|
||||
Passed: 1,
|
||||
}
|
||||
if err := writeSummaryJSON(path, summary); err != nil {
|
||||
t.Fatalf("writeSummaryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read summary: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(string(b), "\n") {
|
||||
t.Fatalf("summary JSON should end with newline: %q", b)
|
||||
}
|
||||
|
||||
var got conformanceSummary
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("summary JSON did not decode: %v", err)
|
||||
}
|
||||
if got.Passed != 1 || len(got.Results) != 1 || got.Results[0].Status != statusPassed {
|
||||
t.Fatalf("summary JSON decoded as %#v, want one passed result", got)
|
||||
}
|
||||
}
|
||||
@@ -207,23 +207,27 @@ func waitFor(reg registry.Registry, names ...string) {
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
os.Exit(runUniverse(*provider))
|
||||
}
|
||||
|
||||
func runUniverse(provider string) int {
|
||||
failures = 0
|
||||
apiKey := ""
|
||||
if *provider == "mock" {
|
||||
if provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else if apiKey = providerKey(*provider); apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
os.Exit(2)
|
||||
} else if apiKey = providerKey(provider); apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
|
||||
return 2
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mUNIVERSE — booting a mini go-micro world (provider: %s)\033[0m\n\n", *provider)
|
||||
fmt.Printf("\n\033[1mUNIVERSE — booting a mini go-micro world (provider: %s)\033[0m\n\n", provider)
|
||||
|
||||
// Infrastructure — all in-memory, all real.
|
||||
reg := registry.NewMemoryRegistry()
|
||||
br := broker.NewMemoryBroker()
|
||||
if err := br.Connect(); err != nil {
|
||||
fmt.Println("broker connect:", err)
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
st := store.NewMemoryStore()
|
||||
@@ -231,7 +235,7 @@ func main() {
|
||||
// Services.
|
||||
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
|
||||
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
|
||||
svc := service.New(service.Name(name), service.Registry(reg), service.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
svc.Handle(h)
|
||||
go svc.Run()
|
||||
}
|
||||
@@ -243,7 +247,8 @@ func main() {
|
||||
agent.Name("concierge"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You notify buyers when their order is confirmed."),
|
||||
agent.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.MaxSteps(5),
|
||||
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
@@ -272,7 +277,7 @@ func main() {
|
||||
)
|
||||
if err := checkout.Register(reg, br, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
defer checkout.Stop()
|
||||
|
||||
@@ -282,7 +287,7 @@ func main() {
|
||||
fmt.Println("\033[1m> event:\033[0m events.order.placed {\"order\":\"order-1\"}")
|
||||
if err := br.Publish("events.order.placed", &broker.Message{Body: []byte(`{"order":"order-1"}`)}); err != nil {
|
||||
fmt.Println("publish:", err)
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
|
||||
// Wait for the run to be checkpointed as failed at "charge".
|
||||
@@ -352,7 +357,8 @@ func main() {
|
||||
|
||||
if failures > 0 {
|
||||
fmt.Printf("\n\033[31m✗ universe failed: %d assertion(s)\033[0m\n", failures)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ universe: booted, survived a crash, resumed, and shut down cleanly\033[0m")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
|
||||
// Go test contract. The harness boots real services, a durable workflow, an
|
||||
// agent, scoped state, and the A2A gateway with only the LLM mocked; running it
|
||||
// here prevents the full services → agents → workflows lifecycle from silently
|
||||
// drifting while developers rely on `go test ./...`.
|
||||
func TestUniverseHarnessContract(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("universe harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
|
||||
if code := runUniverse("mock"); code != 0 {
|
||||
t.Fatalf("universe harness exited with code %d", code)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ guides:
|
||||
url: /docs/guides/atlascloud-integration.html
|
||||
- title: AI Provider Guide
|
||||
url: /docs/guides/ai-provider-guide.html
|
||||
- title: Provider Conformance
|
||||
url: /docs/guides/provider-conformance.html
|
||||
- title: Comparison
|
||||
url: /docs/guides/comparison.html
|
||||
- title: Migration Guides
|
||||
@@ -80,6 +82,7 @@ search_order:
|
||||
- /docs/plugins.html
|
||||
- /docs/examples/
|
||||
- /docs/examples/realworld/
|
||||
- /docs/guides/provider-conformance.html
|
||||
- /docs/guides/comparison.html
|
||||
- /docs/guides/migration/
|
||||
- /docs/architecture/
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
layout: blog
|
||||
title: "How Go Micro Builds Itself"
|
||||
permalink: /blog/31
|
||||
description: "Go Micro is increasingly built by an autonomous loop of two AI agents — Codex implementing scoped increments, Claude Code orchestrating, the human setting direction. Here's how the loop actually works, including the parts that broke."
|
||||
---
|
||||
|
||||
# How Go Micro Builds Itself
|
||||
|
||||
*June 25, 2026 • By the Go Micro Team*
|
||||
|
||||
Go Micro is an agent harness. The most honest test of that claim is to use agents to build it — so increasingly, we do. A scheduled loop of two AI agents now opens issues, writes increments, and merges its own pull requests against this repo, on a cadence, with a human setting direction rather than typing the code.
|
||||
|
||||
This isn't a stunt. If a harness is good enough to operate a loop that builds itself, that's evidence it's good enough to operate the loop that builds *your* software. So we pointed the thesis at itself and wired up the loop. This post is how it actually works — including the parts that didn't, because the failure modes are the interesting part.
|
||||
|
||||
## Two agents and a human
|
||||
|
||||
The work splits across three roles:
|
||||
|
||||
- **Codex** is the serial builder. It takes one scoped task at a time, implements it, runs the build, tests, and linter, and opens a pull request.
|
||||
- **Claude Code** is the orchestrator: it sets up the machinery, reviews, integrates, and handles the judgment calls Codex shouldn't make alone.
|
||||
- **The human** sets direction and owns taste — brand and positioning copy, breaking public API changes, architectural decisions. Those never merge autonomously.
|
||||
|
||||
Everything else is automated.
|
||||
|
||||
## The mechanism
|
||||
|
||||
Each cycle is deliberately boring, which is the point:
|
||||
|
||||
1. A scheduled workflow opens a **fresh tracking issue** and dispatches Codex on it with a single instruction: pick the highest-value improvement that advances the [North Star](/docs/guides/agent-harness.html), implement it, verify it builds and passes tests and lint, and open a PR.
|
||||
2. Codex does the work on its own branch and opens the PR.
|
||||
3. GitHub **native auto-merge** lands it the moment the required CI checks go green — build, tests, golangci-lint. There is no human approval step. **CI is the only gate**, and that's not an approval, it's just a refusal to ship broken code.
|
||||
|
||||
Every increment is small, single-concern, and reversible. Nothing clever survives that can't pass the same checks a human contributor's PR would.
|
||||
|
||||
## Three altitudes
|
||||
|
||||
One loop only produces increments; it doesn't know whether they're adding up to anything. So there are three passes at different altitudes:
|
||||
|
||||
- An **architect** pass reviews the whole framework against the thesis every few days — API coherence, gaps in the services → agents → workflows lifecycle, drift — and files scoped issues. It decides *what* to build.
|
||||
- The **hourly increment loop** builds those issues.
|
||||
- A **DevRel** pass audits the README, website, docs, and blog each day for coherence, and surfaces things worth writing about. (This post is the kind of thing it's meant to catch.)
|
||||
|
||||
The architect points, the loop builds, DevRel keeps the story honest. Direction flows down; code flows up.
|
||||
|
||||
## The parts that broke
|
||||
|
||||
Wiring an autonomous loop is mostly plumbing and failure modes, which is exactly why it's a good test of a harness. A few we hit:
|
||||
|
||||
- The agent's "open a pull request" tool turned out to be a **stub** — it recorded the PR's title and body and returned them for a downstream step, but never pushed a branch or called the API. The agent cheerfully reported "opened a PR" every time, and no PR ever appeared. The fix was to stop trusting the tool and have the agent push and open the PR itself.
|
||||
- Dispatching every run from a single tracking issue made the agent derive the **same branch name** each time, so the first increment opened a PR and the rest silently collided. One fresh issue per run fixed it.
|
||||
- At one point an increment helpfully rewrote the repo's own agent instructions to point *back* at the broken tool. An autonomous loop will faithfully encode its own mistakes, so the guardrails have to be explicit.
|
||||
|
||||
None of these are exotic. They're the ordinary reality of operating an agent loop: tools that lie, state that collides, instructions that drift. The things the harness ships — observability, durable runs, resilience, guardrails — are the things you reach for the moment you try to run a loop like this in earnest.
|
||||
|
||||
## What it produces
|
||||
|
||||
The increments are unglamorous and real. Recent ones hardened the agent run loop with **OpenTelemetry run timelines** and a `micro runs` command to inspect them, correlated those timelines with trace spans, added **retry backoff** to durable flows, and made flow steps **cancellation-safe** so a canceled run stops retrying instead of burning its budget. Each one landed as a small PR that passed CI on its own.
|
||||
|
||||
That's the texture of the work: not a model writing a framework in one shot, but a loop making it a little better, continuously, under a gate that keeps it honest.
|
||||
|
||||
## The loop is the proof
|
||||
|
||||
We think the future of agentic software is scheduled, looping, work-performing agents — not chat. Go Micro is built by exactly that, against its own repo. The human still sets direction and owns the calls that need taste; CI is the gate; everything is reversible. Within those bounds, the harness builds itself.
|
||||
|
||||
If it can do that, it can build yours.
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is an open source agent harness and service framework for Go. [Star us on GitHub](https://github.com/micro/go-micro).*
|
||||
|
||||
<div class="post-nav">
|
||||
<div><a href="/blog/30">← Go Micro is an Agent Harness</a></div>
|
||||
<div><a href="/blog/">All Posts</a></div>
|
||||
</div>
|
||||
@@ -11,6 +11,13 @@ permalink: /blog/
|
||||
|
||||
<div class="posts">
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/31">How Go Micro Builds Itself</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">June 25, 2026</p>
|
||||
<p>Go Micro is increasingly built by an autonomous loop of two AI agents — Codex writing scoped increments, Claude Code orchestrating, the human setting direction. Here's how the loop actually works, including the parts that broke.</p>
|
||||
<a href="/blog/31">Read more →</a>
|
||||
</article>
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/30">Go Micro is an Agent Harness</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">June 24, 2026</p>
|
||||
|
||||
@@ -2,58 +2,64 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance for migrating to secure TLS certificate verification in go-micro v5.
|
||||
Go Micro v6 verifies TLS certificates by default. This guide is for teams
|
||||
upgrading from v5, where TLS verification was disabled by default for backward
|
||||
compatibility.
|
||||
|
||||
## Current Status (v5)
|
||||
## Current Status (v6)
|
||||
|
||||
**Default Behavior**: TLS certificate verification is **disabled** by default (`InsecureSkipVerify: true`)
|
||||
**Default Behavior**: TLS certificate verification is **enabled** by default
|
||||
(`InsecureSkipVerify: false`).
|
||||
|
||||
**Reason**: Backward compatibility with existing deployments to avoid breaking production systems during routine upgrades.
|
||||
**What changed from v5**: v5 allowed `MICRO_TLS_SECURE=true` to opt into
|
||||
certificate verification. In v6, secure verification is the default and
|
||||
`MICRO_TLS_SECURE` is no longer used.
|
||||
|
||||
**Security Risk**: The default behavior is vulnerable to man-in-the-middle (MITM) attacks.
|
||||
**Development escape hatch**: for local self-signed certificates only, set
|
||||
`MICRO_TLS_INSECURE=true` or provide an explicit insecure TLS config.
|
||||
|
||||
## Migration Path
|
||||
## Migration Path from v5
|
||||
|
||||
### Option 1: Enable Secure Mode (RECOMMENDED)
|
||||
### 1. Remove the old opt-in flag
|
||||
|
||||
Set the environment variable to enable certificate verification:
|
||||
Delete any use of the v5-only environment variable:
|
||||
|
||||
```bash
|
||||
export MICRO_TLS_SECURE=true
|
||||
unset MICRO_TLS_SECURE
|
||||
```
|
||||
|
||||
This enables proper TLS certificate verification while maintaining compatibility with v5.
|
||||
No replacement is required for production: verification is already on in v6.
|
||||
|
||||
### Option 2: Use SecureConfig Directly
|
||||
### 2. Use the default secure config
|
||||
|
||||
In your code, explicitly use the secure configuration:
|
||||
Most services need no TLS-specific code. If you configure TLS explicitly, use a standard `crypto/tls` config with verification enabled:
|
||||
|
||||
```go
|
||||
import (
|
||||
"crypto/tls"
|
||||
"go-micro.dev/v6/broker"
|
||||
mls "go-micro.dev/v6/util/tls"
|
||||
)
|
||||
|
||||
// Create broker with secure TLS config
|
||||
// Create broker with certificate verification enabled.
|
||||
b := broker.NewHttpBroker(
|
||||
broker.TLSConfig(mls.SecureConfig()),
|
||||
broker.TLSConfig(&tls.Config{MinVersion: tls.VersionTLS12}),
|
||||
)
|
||||
```
|
||||
|
||||
### Option 3: Provide Custom TLS Configuration
|
||||
### 3. Provide a custom trust root when needed
|
||||
|
||||
For fine-grained control, provide your own TLS configuration:
|
||||
For private CAs, provide your own TLS configuration:
|
||||
|
||||
```go
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"go-micro.dev/v6/broker"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Load CA certificates
|
||||
caCert, err := ioutil.ReadFile("/path/to/ca-cert.pem")
|
||||
caCert, err := os.ReadFile("/path/to/ca-cert.pem")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -73,52 +79,61 @@ b := broker.NewHttpBroker(
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Use insecure mode only for local development
|
||||
|
||||
If a development environment still uses self-signed certificates that are not in
|
||||
your trust store, opt out explicitly:
|
||||
|
||||
```bash
|
||||
export MICRO_TLS_INSECURE=true
|
||||
```
|
||||
|
||||
or in code:
|
||||
|
||||
```go
|
||||
broker.TLSConfig(&tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
|
||||
```
|
||||
|
||||
Do not use insecure mode in production.
|
||||
|
||||
## Production Deployment Strategy
|
||||
|
||||
### Rolling Upgrade Considerations
|
||||
|
||||
The current implementation maintains backward compatibility, allowing safe rolling upgrades:
|
||||
The default changed at the v6 major-version boundary. Before rolling v6 into a
|
||||
fleet that uses TLS, verify that:
|
||||
|
||||
1. **Mixed Version Deployments**: v5 instances can communicate regardless of TLS security settings
|
||||
2. **No Immediate Breaking Changes**: Systems continue working with existing behavior
|
||||
3. **Gradual Migration**: Enable security incrementally across your infrastructure
|
||||
1. All services present certificates trusted by their peers.
|
||||
2. Private or self-signed CAs are installed consistently on every host.
|
||||
3. Certificates include the DNS names or IP subject alternative names used by
|
||||
clients.
|
||||
4. Any deliberate development-only insecure settings are excluded from
|
||||
production manifests.
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
1. **Test in Staging**:
|
||||
```bash
|
||||
# In staging environment
|
||||
export MICRO_TLS_SECURE=true
|
||||
```
|
||||
|
||||
2. **Deploy with Feature Flag**: Use environment-based configuration for gradual rollout
|
||||
|
||||
3. **Monitor for Issues**: Watch for TLS handshake failures or certificate validation errors
|
||||
|
||||
4. **Full Production Rollout**: Once validated, enable across all services
|
||||
1. **Test in Staging** with the same certificate chain and service names used in
|
||||
production.
|
||||
2. **Remove v5 flags** such as `MICRO_TLS_SECURE`; they no longer control v6.
|
||||
3. **Monitor for Issues**: watch for TLS handshake failures or certificate
|
||||
validation errors.
|
||||
4. **Use explicit insecure mode only in dev** when a short-lived environment
|
||||
cannot yet provide trusted certificates.
|
||||
|
||||
### Multi-Host/Multi-Process Considerations
|
||||
|
||||
**Certificate Trust**: When enabling secure mode, ensure:
|
||||
**Certificate Trust**: With secure mode as the default, ensure:
|
||||
|
||||
1. All hosts trust the same root CAs
|
||||
2. Self-signed certificates are properly distributed if used
|
||||
3. Certificate validity periods are monitored
|
||||
4. Certificate chains are complete
|
||||
1. All hosts trust the same root CAs.
|
||||
2. Self-signed certificates are properly distributed if used.
|
||||
3. Certificate validity periods are monitored.
|
||||
4. Certificate chains are complete.
|
||||
|
||||
**Service Mesh Alternative**: Consider using a service mesh (Istio, Linkerd, etc.) for:
|
||||
- Automatic mTLS between services
|
||||
- Certificate management and rotation
|
||||
- No application code changes required
|
||||
|
||||
## Future Changes (v6)
|
||||
|
||||
In go-micro v6, the default will change to **secure by default**:
|
||||
|
||||
- `InsecureSkipVerify: false` (certificate verification enabled)
|
||||
- Breaking change requiring major version bump
|
||||
- Migration completed before v6 release avoids disruption
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
### Verify Secure Mode is Active
|
||||
@@ -127,14 +142,12 @@ In go-micro v6, the default will change to **secure by default**:
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
mls "go-micro.dev/v6/util/tls"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Setenv("MICRO_TLS_SECURE", "true")
|
||||
config := mls.Config()
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
fmt.Printf("InsecureSkipVerify: %v (should be false)\n", config.InsecureSkipVerify)
|
||||
}
|
||||
```
|
||||
@@ -155,7 +168,7 @@ Create a test service and verify it:
|
||||
**Solution**:
|
||||
1. Add the CA certificate to the trusted root CAs
|
||||
2. Use a properly signed certificate
|
||||
3. For development only: Use `InsecureConfig()` explicitly
|
||||
3. For development only: use `MICRO_TLS_INSECURE=true` or an explicit insecure TLS config
|
||||
|
||||
### Issue: "x509: certificate has expired"
|
||||
|
||||
@@ -166,24 +179,17 @@ Create a test service and verify it:
|
||||
2. Implement certificate rotation
|
||||
3. Monitor certificate expiry dates
|
||||
|
||||
### Issue: Services can't communicate after enabling secure mode
|
||||
### Issue: Services can't communicate after upgrading to v6
|
||||
|
||||
**Cause**: Mixed certificate authorities or missing certificates
|
||||
**Cause**: Certificates that v5 accepted by default are now verified.
|
||||
|
||||
**Solution**:
|
||||
1. Ensure all services use certificates from the same CA
|
||||
1. Ensure all services use certificates from a trusted CA
|
||||
2. Distribute CA certificates to all nodes
|
||||
3. Verify certificate SANs match service addresses
|
||||
4. Use insecure mode only as a temporary local-development workaround
|
||||
|
||||
## Questions?
|
||||
|
||||
For issues or questions about TLS security migration, please:
|
||||
- Open an issue on GitHub
|
||||
- Check the documentation at https://go-micro.dev/docs/
|
||||
- Review the security guidelines
|
||||
|
||||
## Security Resources
|
||||
|
||||
- [OWASP TLS Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html)
|
||||
- [Go TLS Documentation](https://pkg.go.dev/crypto/tls)
|
||||
- [Certificate Best Practices](https://www.ssl.com/guide/ssl-best-practices/)
|
||||
For issues or questions about TLS security migration, open an issue on GitHub or
|
||||
check the documentation at https://go-micro.dev/docs/.
|
||||
|
||||
@@ -2,66 +2,36 @@
|
||||
|
||||
## What Changed
|
||||
|
||||
The TLS configuration in go-micro now includes a security deprecation warning.
|
||||
Go Micro v6 verifies TLS certificates by default. This completes the v5 security
|
||||
migration where verification was opt-in.
|
||||
|
||||
## Current Behavior (v5.x)
|
||||
## Current Behavior (v6.x)
|
||||
|
||||
**Default**: TLS certificate verification is **disabled** for backward compatibility
|
||||
- This maintains existing behavior to avoid breaking production deployments
|
||||
- A deprecation warning is logged once per process startup
|
||||
**Default**: TLS certificate verification is **enabled**.
|
||||
- `MICRO_TLS_SECURE` was a v5 opt-in flag and is no longer used.
|
||||
- For local development with untrusted self-signed certificates, opt out
|
||||
explicitly with `MICRO_TLS_INSECURE=true` or an explicit insecure TLS config.
|
||||
|
||||
**Why**: Changing the default to secure would be a **breaking change** that could disrupt:
|
||||
- Production systems during routine upgrades
|
||||
- Distributed systems with mixed versions
|
||||
- Services using self-signed certificates
|
||||
## Production Recommendation
|
||||
|
||||
## How to Enable Security (Recommended)
|
||||
|
||||
### Option 1: Environment Variable
|
||||
|
||||
```bash
|
||||
export MICRO_TLS_SECURE=true
|
||||
```
|
||||
|
||||
### Option 2: Use SecureConfig
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v6/broker"
|
||||
mls "go-micro.dev/v6/util/tls"
|
||||
)
|
||||
|
||||
broker := broker.NewHttpBroker(
|
||||
broker.TLSConfig(mls.SecureConfig()),
|
||||
)
|
||||
```
|
||||
For production deployments:
|
||||
1. Use CA-signed certificates or distribute your private CA to every host.
|
||||
2. Remove old `MICRO_TLS_SECURE` settings from v5-era manifests.
|
||||
3. Do not set `MICRO_TLS_INSECURE=true` in production.
|
||||
4. Consider service mesh mTLS (Istio, Linkerd) if certificate lifecycle should be
|
||||
managed outside the application.
|
||||
|
||||
## Migration Timeline
|
||||
|
||||
- **v5.x (Current)**: Insecure by default, opt-in security via `MICRO_TLS_SECURE=true`
|
||||
- **v6.x (Future)**: Secure by default (breaking change with major version bump)
|
||||
|
||||
## Why This Approach?
|
||||
|
||||
This addresses the concerns raised about:
|
||||
|
||||
1. **Major version requirements**: No breaking change in v5, deferred to v6
|
||||
2. **Cross-host compatibility**: All hosts use same default behavior
|
||||
3. **Production safety**: Existing deployments continue working during upgrades
|
||||
4. **Migration path**: Clear opt-in path with documentation
|
||||
- **v5.x**: Insecure by default, opt-in security via `MICRO_TLS_SECURE=true`.
|
||||
- **v6.x current**: Secure by default; use `MICRO_TLS_INSECURE=true` only for an
|
||||
explicit development opt-out.
|
||||
|
||||
## Documentation
|
||||
|
||||
See [SECURITY_MIGRATION.md](SECURITY_MIGRATION.html) for detailed migration guide.
|
||||
|
||||
## Security Recommendation
|
||||
|
||||
For production deployments:
|
||||
1. Test with `MICRO_TLS_SECURE=true` in staging
|
||||
2. Use proper CA-signed certificates
|
||||
3. Consider service mesh (Istio, Linkerd) for automatic mTLS
|
||||
4. Plan migration before v6 release
|
||||
See [SECURITY_MIGRATION.md](SECURITY_MIGRATION.html) for the detailed migration
|
||||
guide.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue on GitHub or check the documentation at https://go-micro.dev/docs/
|
||||
Open an issue on GitHub or check the documentation at https://go-micro.dev/docs/.
|
||||
|
||||
@@ -66,8 +66,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService(
|
||||
micro.Name("example"),
|
||||
service := micro.NewService("example",
|
||||
)
|
||||
service.Init()
|
||||
if err := service.Run(); err != nil {
|
||||
|
||||
@@ -31,12 +31,11 @@ Use **mDNS as the default registry** for service discovery.
|
||||
|
||||
```go
|
||||
// Default - uses mDNS automatically
|
||||
svc := micro.NewService(micro.Name("myservice"))
|
||||
svc := micro.NewService("myservice")
|
||||
|
||||
// Production - swap to Consul
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
svc := micro.NewService("myservice",
|
||||
micro.Registry(reg),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ Implement **progressive configuration** where:
|
||||
|
||||
### Level 1: Zero Config (Development)
|
||||
```go
|
||||
svc := micro.NewService(micro.Name("hello"))
|
||||
svc := micro.NewService("hello")
|
||||
svc.Run()
|
||||
```
|
||||
|
||||
@@ -62,8 +62,7 @@ b := nats.NewNatsBroker(
|
||||
nats.DrainConnection(),
|
||||
)
|
||||
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
svc := micro.NewService("myservice",
|
||||
micro.Version("1.2.3"),
|
||||
micro.Registry(reg),
|
||||
micro.Broker(b),
|
||||
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService()
|
||||
service := micro.NewService("publisher")
|
||||
service.Init()
|
||||
|
||||
// Publish a message
|
||||
@@ -73,7 +73,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := bnats.NewNatsBroker()
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc := micro.NewService("publisher", micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -88,7 +88,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := rabbitmq.NewBroker()
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc := micro.NewService("publisher", micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ func (g *Greeter) Hello(ctx context.Context, req *struct{}, rsp *struct{Msg stri
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
service := micro.NewService("greeter",
|
||||
)
|
||||
service.Init()
|
||||
micro.RegisterHandler(service.Server(), new(Greeter))
|
||||
|
||||
@@ -53,8 +53,7 @@ No code changes required. The framework internally wires the selected implementa
|
||||
## Equivalent Code Configuration
|
||||
|
||||
```go
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
service := micro.NewService("helloworld",
|
||||
micro.Broker(nats.NewBroker()),
|
||||
micro.Transport(natstransport.NewTransport()),
|
||||
micro.Registry(consul.NewRegistry(registry.Addrs("127.0.0.1:8500"))),
|
||||
|
||||
@@ -54,8 +54,7 @@ curl -XPOST \
|
||||
Set a fixed address:
|
||||
|
||||
```go
|
||||
svc := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
svc := micro.NewService("helloworld",
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := bnats.NewNatsBroker()
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc := micro.NewService("nats-pubsub", micro.Broker(b))
|
||||
svc.Init()
|
||||
|
||||
// subscribe
|
||||
|
||||
@@ -79,8 +79,7 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
svc := micro.NewService(
|
||||
micro.Name("users"),
|
||||
svc := micro.NewService("users",
|
||||
micro.Version("1.0.0"),
|
||||
)
|
||||
|
||||
@@ -172,8 +171,7 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
svc := micro.NewService(
|
||||
micro.Name("orders"),
|
||||
svc := micro.NewService("orders",
|
||||
micro.Version("1.0.0"),
|
||||
)
|
||||
|
||||
@@ -254,8 +252,7 @@ func (g *Gateway) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService(
|
||||
micro.Name("api.gateway"),
|
||||
svc := micro.NewService("api.gateway",
|
||||
)
|
||||
svc.Init()
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
svc := micro.NewService("myservice",
|
||||
micro.BeforeStop(func() error {
|
||||
logger.Info("Service stopping, running cleanup...")
|
||||
return cleanup()
|
||||
@@ -233,8 +232,7 @@ func main() {
|
||||
app.AddWorker(&Worker{name: "cleanup"})
|
||||
app.AddWorker(&Worker{name: "metrics"})
|
||||
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
svc := micro.NewService("myservice",
|
||||
micro.BeforeStop(func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func main() {
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService(micro.Registry(reg))
|
||||
svc := micro.NewService("consul-registry", micro.Registry(reg))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := postgres.NewStore()
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc := micro.NewService("postgres-store", micro.Store(st))
|
||||
svc.Init()
|
||||
|
||||
_ = store.Write(&store.Record{Key: "foo", Value: []byte("bar")})
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func main() {
|
||||
t := tnats.NewTransport()
|
||||
svc := micro.NewService(micro.Transport(t))
|
||||
svc := micro.NewService("nats-transport", micro.Transport(t))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ A card looks like:
|
||||
"url": "https://agents.example.com/agents/task-mgr",
|
||||
"version": "1.0.0",
|
||||
"protocolVersion": "0.3.0",
|
||||
"capabilities": { "streaming": false, "pushNotifications": false },
|
||||
"capabilities": { "streaming": true, "pushNotifications": true },
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [{ "id": "chat", "name": "Chat", "tags": ["task", "project"] }]
|
||||
@@ -100,7 +100,40 @@ curl -s https://agents.example.com/agents/task-mgr \
|
||||
}
|
||||
```
|
||||
|
||||
Retrieve a task later with `tasks/get` (`params: { "id": "…" }`).
|
||||
Retrieve a task later with `tasks/get` (`params: { "id": "…" }`). To continue
|
||||
the same piece of work, send another `message/send` with the previous `taskId`
|
||||
and `contextId`. The gateway preserves the task id, context id, and prior
|
||||
history, then appends the new user turn and agent reply. That makes a remote
|
||||
A2A task fit the Go Micro lifecycle: services are still invoked through the
|
||||
agent's normal tools, the agent keeps task context across turns, and a workflow
|
||||
can poll one task id as the conversation progresses.
|
||||
|
||||
## Push notifications
|
||||
|
||||
Operators can register a task callback with
|
||||
`tasks/pushNotificationConfig/set`:
|
||||
|
||||
```bash
|
||||
curl -s https://agents.example.com/agents/task-mgr \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{
|
||||
"jsonrpc": "2.0", "id": 2,
|
||||
"method": "tasks/pushNotificationConfig/set",
|
||||
"params": {
|
||||
"id": "task-id",
|
||||
"pushNotificationConfig": {
|
||||
"url": "https://workflow.example.com/a2a/tasks",
|
||||
"token": "optional-bearer-token"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
The gateway stores one callback per retained task and POSTs the latest task
|
||||
snapshot to that URL whenever the task changes. Delivery is best effort: failures
|
||||
do not fail the agent turn, and there is no retry queue in the in-memory gateway.
|
||||
Use `tasks/get` as the source of truth after a missed callback or receiver
|
||||
outage. If a token is configured, it is sent as `Authorization: Bearer <token>`.
|
||||
|
||||
## Calling out to other agents
|
||||
|
||||
@@ -132,21 +165,24 @@ yet terminal, it polls `tasks/get` until it completes.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the synchronous JSON-RPC binding:
|
||||
This is the JSON-RPC binding for task execution:
|
||||
|
||||
- **`message/send`** runs the agent and returns a completed `Task`.
|
||||
- **`message/stream`** streams the completed `Task` as an SSE `data:` event, giving A2A clients a streaming-compatible path while the underlying agent call remains synchronous.
|
||||
- **`tasks/get`** returns a recent task by id.
|
||||
- **Multi-turn continuation** keeps task state when a new message includes the previous `taskId`.
|
||||
- **`tasks/pushNotificationConfig/set` / `get`** stores and reads a task callback for best-effort update delivery.
|
||||
- **Agent Card** discovery, generated from the registry.
|
||||
|
||||
Both directions work: the gateway exposes your agents, and `a2a.Client` (via `flow.A2A` or `delegate` to a URL) calls external ones.
|
||||
|
||||
Not yet supported (advertised as such on the card, so clients negotiate correctly):
|
||||
|
||||
- **`message/stream`** (SSE streaming) and `tasks/resubscribe`.
|
||||
- **`tasks/resubscribe`** for reconnecting to a live stream.
|
||||
- Multi-turn `input-required` tasks.
|
||||
- Push notifications.
|
||||
|
||||
These are the natural follow-ups; the synchronous binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
|
||||
These are the natural follow-ups; the task binding is what makes a Go Micro
|
||||
agent both reachable from, and able to reach, the A2A ecosystem today.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user