Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6caec5a9a | |||
| 205d6a55a3 | |||
| 7588a427cc | |||
| 7b9937068d | |||
| f8d8cb39a7 | |||
| 87a0c0c93b | |||
| f31a7acf0b | |||
| 4279a5eed3 | |||
| c39a846d3b | |||
| b707c7e305 | |||
| 5bfc37708d | |||
| 4d852c5e36 | |||
| f7a3e8461e | |||
| 87eb540a04 | |||
| 48e05385c7 | |||
| cb10149f54 | |||
| 66438697b3 | |||
| 6e1c11ca74 | |||
| 44c08a6bd2 | |||
| 9c01202599 | |||
| b4cfe51115 | |||
| 2221834707 | |||
| 5813f53117 | |||
| 72e6161b02 | |||
| 1789819c39 | |||
| 8676dcacf2 | |||
| 22b3b9ca22 | |||
| 4ca8948ba4 | |||
| 28cbf0be7e | |||
| e8e6cb1210 | |||
| 2a06a043bd | |||
| 6eef58f815 | |||
| 6da799d64f | |||
| 220b370641 | |||
| ac2163ef49 | |||
| 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 | |||
| 7a0ca65b68 | |||
| 28433f2db8 | |||
| cb61ccb9d7 | |||
| fbb5fd46ae | |||
| ff6af5d46b | |||
| 89ae0a10eb | |||
| 666e1f765e | |||
| 5a0df7991e | |||
| 631c58cb75 | |||
| 57e83ca9a0 | |||
| 8d59779465 | |||
| 3d0c5237f0 | |||
| cf1f42d15b | |||
| 728ea5eae7 | |||
| 5e9accfd45 | |||
| 0adb4f0aac | |||
| ee44926191 | |||
| 09cf00b5ec | |||
| da5fa1f27c | |||
| a8ea60120e | |||
| d7050e9cc2 | |||
| 536806119e | |||
| ab2c092693 | |||
| 7144cddc00 | |||
| 9f28cc4c86 | |||
| 8a19dc2d7c | |||
| daacd4830f | |||
| 844ab4a14b | |||
| 156b051191 | |||
| 21a6746d86 | |||
| 2c04f61331 | |||
| 634fc9bced | |||
| a503628ef1 | |||
| 3f40028a7f | |||
| b9665ad196 | |||
| 053103fb7f | |||
| 0ba2253357 | |||
| 2339155ff2 | |||
| 3e885308a0 | |||
| 4311b73361 | |||
| c3542127dd | |||
| 06659032a9 | |||
| 8cad95cc29 | |||
| 2cbd6264d7 | |||
| aa7cd6dc3b | |||
| 456ff18176 | |||
| cdc6ee9aa1 | |||
| e5bf72abd8 | |||
| 232126476a | |||
| 358b2ffd27 | |||
| 45346337e2 | |||
| 4b8b4d7bab | |||
| 97f932a2e7 | |||
| ca87efef2f | |||
| d54cfab1bd | |||
| 46c56ef7df |
@@ -1 +1,2 @@
|
||||
github: asim
|
||||
custom: ["https://go-micro.dev/support"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Commercial Support / Consulting
|
||||
about: Inquire about paid support, consulting, training, or a retainer
|
||||
title: '[SUPPORT] '
|
||||
labels: commercial-support
|
||||
assignees: asim
|
||||
---
|
||||
|
||||
## What are you building?
|
||||
A short description of your project and how you're using (or planning to use) Go Micro.
|
||||
|
||||
## What do you need?
|
||||
- [ ] Production support / retainer (priority fixes, direct line, response SLA)
|
||||
- [ ] Consulting (integration, architecture, agent design)
|
||||
- [ ] Training / onboarding for a team
|
||||
- [ ] Sponsored feature or fix
|
||||
- [ ] Not sure yet — let's talk
|
||||
|
||||
## Scale & timeline
|
||||
Team size, where you're running it, and any timeline that matters.
|
||||
|
||||
## Anything else?
|
||||
Links, context, constraints. For anything you'd rather keep private, become a [sponsor](https://github.com/sponsors/asim) and message directly.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💖 Sponsor Go Micro
|
||||
url: https://github.com/sponsors/asim
|
||||
about: Fund ongoing development and see your name or logo on the project.
|
||||
- name: 📖 Documentation
|
||||
url: https://go-micro.dev/docs
|
||||
about: Guides, examples, and the full reference.
|
||||
@@ -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)."
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Continuous Improvement
|
||||
|
||||
# Durable backbone for the autonomous improvement loop
|
||||
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
#
|
||||
# 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 (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
|
||||
# follows). Until that secret is set the workflow runs but no-ops — this avoids
|
||||
# piling up @codex comments that Codex silently ignores. The moment the secret is
|
||||
# added the loop activates with no further change.
|
||||
#
|
||||
# Each run opens a FRESH issue and dispatches Codex there, rather than re-commenting
|
||||
# on one tracker issue. Codex derives its PR branch name from the triggering issue's
|
||||
# context, so repeated dispatches on a single issue all collapse onto one branch name
|
||||
# (codex/github-mention-<that-issue-slug>) — the first increment opens a PR, the rest
|
||||
# collide on the occupied branch and silently fail to open one. A unique issue per
|
||||
# run gives each increment its own branch and a clean PR. The dispatch asks Codex to
|
||||
# "Closes #<issue>" so each tracking issue auto-closes when its PR merges.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "29 * * * *" # hourly, off-minute (tune as needed)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: continuous-improvement
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open a fresh increment 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 dispatch."
|
||||
echo "Codex ignores comments from the github-actions bot, so posting now"
|
||||
echo "would only create noise. Add a CODEX_TRIGGER_TOKEN secret (a PAT for"
|
||||
echo "a user account Codex follows) to activate the loop."
|
||||
exit 0
|
||||
fi
|
||||
# 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 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."
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Harness (E2E)
|
||||
|
||||
# Runs the end-to-end harnesses for agents, services, flows, and provider
|
||||
# conformance. The default job uses deterministic mock LLMs and needs no
|
||||
# secrets. A second job runs the same harnesses against the live provider set and
|
||||
# skips providers whose secrets are absent, so scheduled conformance
|
||||
# remains safe in no-key forks while still failing configured providers that drift.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
name: Harnesses (mock LLM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: 0→1 scaffold contract
|
||||
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
|
||||
- name: Universe end-to-end (asserts; exits non-zero on failure)
|
||||
run: go run ./internal/harness/universe
|
||||
- name: Agent-flow harness
|
||||
run: go run ./internal/harness/agent-flow
|
||||
- name: 0→hero run/chat/inspect reference scenario
|
||||
run: ./internal/harness/zero-to-hero-ci/run.sh
|
||||
|
||||
harness-live:
|
||||
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
|
||||
# by hand (Actions → Harness → Run workflow) when changing the agent,
|
||||
# flow, or AI internals and you want a real-model check.
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Agent provider conformance matrix
|
||||
env:
|
||||
GO_MICRO_AGENT_CONFORMANCE_LIVE: "1"
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: go test ./agent -run TestAgentProviderConformanceMatrix -count=1 -v
|
||||
- name: Provider conformance against live models
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: |
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-summary-markdown provider-conformance-summary.md \
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
- name: Publish provider conformance summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f provider-conformance-summary.md ]; then
|
||||
cat provider-conformance-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -f provider-capabilities.md ]; then
|
||||
{
|
||||
echo
|
||||
echo "## Registered provider capabilities"
|
||||
echo
|
||||
cat provider-capabilities.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Upload provider conformance summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: provider-conformance
|
||||
path: |
|
||||
provider-conformance-summary.json
|
||||
provider-conformance-summary.md
|
||||
provider-capabilities.md
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- "**"
|
||||
jobs:
|
||||
golangci:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.24
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.5.0
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- "**"
|
||||
jobs:
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Universe (E2E)
|
||||
|
||||
# Spins up a mini go-micro world end-to-end — services, a durable flow
|
||||
# that crashes and resumes, and an agent — then shuts it down. Everything
|
||||
# is real except the LLM, which is mocked, so the default job is
|
||||
# deterministic and needs no secrets. A second job runs the same scenario
|
||||
# against a live model when an API key secret is configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
universe:
|
||||
name: Mini universe (mock LLM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- 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
|
||||
run: go run ./internal/harness/plan-delegate
|
||||
|
||||
universe-live:
|
||||
name: Mini universe (live LLM, if key present)
|
||||
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
|
||||
# by hand (Actions → Universe → Run workflow) when changing the agent,
|
||||
# flow, or AI internals and you want a real-model check.
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Universe against a live model (AtlasCloud)
|
||||
env:
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$ATLASCLOUD_API_KEY" ]; then
|
||||
echo "No ATLASCLOUD_API_KEY secret configured — skipping the live run."
|
||||
exit 0
|
||||
fi
|
||||
go run ./internal/harness/universe -provider atlascloud
|
||||
@@ -62,3 +62,8 @@ examples/mcp/hello/hello
|
||||
/plan-delegate
|
||||
/agent-plan-delegate
|
||||
/micro-mcp-gateway
|
||||
|
||||
# Local Jekyll / Bundler artifacts
|
||||
internal/website/.bundle/
|
||||
internal/website/_site/
|
||||
internal/website/.jekyll-cache/
|
||||
|
||||
+57
-245
@@ -1,251 +1,63 @@
|
||||
# This file contains all available configuration options
|
||||
# with their default values.
|
||||
version: "2"
|
||||
|
||||
# options for analysis running
|
||||
run:
|
||||
# go: '1.18'
|
||||
# default concurrency is a available CPU number
|
||||
# concurrency: 4
|
||||
|
||||
# timeout for analysis, e.g. 30s, 5m, default is 1m
|
||||
deadline: 10m
|
||||
|
||||
# exit code when at least one issue was found, default is 1
|
||||
issues-exit-code: 1
|
||||
|
||||
# include test files or not, default is true
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
# which files to skip: they will be analyzed, but issues from them
|
||||
# won't be reported. Default value is empty list, but there is
|
||||
# no need to include all autogenerated files, we confidently recognize
|
||||
# autogenerated files. If it's not please let us know.
|
||||
skip-files:
|
||||
[]
|
||||
# - .*\\.pb\\.go$
|
||||
|
||||
allow-parallel-runners: true
|
||||
|
||||
# list of build tags, all linters use it. Default is empty list.
|
||||
build-tags: []
|
||||
|
||||
# output configuration options
|
||||
output:
|
||||
# Format: colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions
|
||||
#
|
||||
# Multiple can be specified by separating them by comma, output can be provided
|
||||
# for each of them by separating format name and path by colon symbol.
|
||||
# Output path can be either `stdout`, `stderr` or path to the file to write to.
|
||||
# Example: "checkstyle:report.json,colored-line-number"
|
||||
#
|
||||
# Default: colored-line-number
|
||||
format: colored-line-number
|
||||
# Print lines of code with issue.
|
||||
# Default: true
|
||||
print-issued-lines: true
|
||||
# Print linter name in the end of issue text.
|
||||
# Default: true
|
||||
print-linter-name: true
|
||||
# Make issues output unique by line.
|
||||
# Default: true
|
||||
uniq-by-line: true
|
||||
# Add a prefix to the output file references.
|
||||
# Default is no prefix.
|
||||
path-prefix: ""
|
||||
# Sort results by: filepath, line and column.
|
||||
sort-results: true
|
||||
|
||||
# all available settings of specific linters
|
||||
linters-settings:
|
||||
wsl:
|
||||
allow-cuddle-with-calls: ["Lock", "RLock", "defer"]
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 60
|
||||
varnamelen:
|
||||
# The longest distance, in source lines, that is being considered a "small scope".
|
||||
# Variables used in at most this many lines will be ignored.
|
||||
# Default: 5
|
||||
max-distance: 26
|
||||
ignore-names:
|
||||
- err
|
||||
- id
|
||||
- ch
|
||||
- wg
|
||||
- mu
|
||||
ignore-decls:
|
||||
- c echo.Context
|
||||
- t testing.T
|
||||
- f *foo.Bar
|
||||
- e error
|
||||
- i int
|
||||
- const C
|
||||
- T any
|
||||
- m map[string]int
|
||||
errcheck:
|
||||
# report about not checking of errors in type assetions: `a := b.(MyStruct)`;
|
||||
# default is false: such cases aren't reported by default.
|
||||
check-type-assertions: true
|
||||
|
||||
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
|
||||
# default is false: such cases aren't reported by default.
|
||||
check-blank: true
|
||||
govet:
|
||||
# report about shadowed variables
|
||||
check-shadowing: false
|
||||
gofmt:
|
||||
# simplify code: gofmt with `-s` option, true by default
|
||||
simplify: true
|
||||
gocyclo:
|
||||
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
||||
min-complexity: 15
|
||||
maligned:
|
||||
# print struct with more effective memory layout or not, false by default
|
||||
suggest-new: true
|
||||
dupl:
|
||||
# tokens count to trigger issue, 150 by default
|
||||
threshold: 100
|
||||
goconst:
|
||||
# minimal length of string constant, 3 by default
|
||||
min-len: 3
|
||||
# minimal occurrences count to trigger, 3 by default
|
||||
min-occurrences: 3
|
||||
depguard:
|
||||
list-type: blacklist
|
||||
# Packages listed here will reported as error if imported
|
||||
packages:
|
||||
- github.com/golang/protobuf/proto
|
||||
misspell:
|
||||
# Correct spellings using locale preferences for US or UK.
|
||||
# Default is to use a neutral variety of English.
|
||||
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
|
||||
locale: US
|
||||
lll:
|
||||
# max line length, lines longer will be reported. Default is 120.
|
||||
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
|
||||
line-length: 120
|
||||
# tab width in spaces. Default to 1.
|
||||
tab-width: 1
|
||||
unused:
|
||||
# treat code as a program (not a library) and report unused exported identifiers; default is false.
|
||||
# XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
|
||||
# if it's called for subdir of a project it can't find funcs usages. All text editor integrations
|
||||
# with golangci-lint call it on a directory with the changed file.
|
||||
check-exported: false
|
||||
unparam:
|
||||
# call graph construction algorithm (cha, rta). In general, use cha for libraries,
|
||||
# and rta for programs with main packages. Default is cha.
|
||||
algo: cha
|
||||
|
||||
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
|
||||
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
|
||||
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
|
||||
# with golangci-lint call it on a directory with the changed file.
|
||||
check-exported: false
|
||||
nakedret:
|
||||
# make an issue if func has more lines of code than this setting and it has naked returns; default is 30
|
||||
max-func-lines: 60
|
||||
nolintlint:
|
||||
allow-unused: false
|
||||
allow-leading-space: false
|
||||
allow-no-explanation: []
|
||||
require-explanation: false
|
||||
require-specific: true
|
||||
prealloc:
|
||||
# XXX: we don't recommend using this linter before doing performance profiling.
|
||||
# For most programs usage of prealloc will be a premature optimization.
|
||||
|
||||
# Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
|
||||
# True by default.
|
||||
simple: true
|
||||
range-loops: true # Report preallocation suggestions on range loops, true by default
|
||||
for-loops: false # Report preallocation suggestions on for loops, false by default
|
||||
cyclop:
|
||||
# the maximal code complexity to report
|
||||
max-complexity: 20
|
||||
gomoddirectives:
|
||||
replace-local: true
|
||||
retract-allow-no-explanation: false
|
||||
exclude-forbidden: true
|
||||
|
||||
linters:
|
||||
enable-all: true
|
||||
disable-all: false
|
||||
fast: false
|
||||
disable:
|
||||
- golint
|
||||
- varcheck
|
||||
- ifshort
|
||||
- structcheck
|
||||
- deadcode
|
||||
# - nosnakecase
|
||||
- interfacer
|
||||
- maligned
|
||||
- scopelint
|
||||
- exhaustivestruct
|
||||
- testpackage
|
||||
- promlinter
|
||||
- nonamedreturns
|
||||
- makezero
|
||||
- gofumpt
|
||||
- nlreturn
|
||||
- thelper
|
||||
# Start from the standard set (errcheck, govet, ineffassign, staticcheck,
|
||||
# unused) and add a few low-noise, high-value linters on top.
|
||||
default: standard
|
||||
enable:
|
||||
- bodyclose
|
||||
- misspell
|
||||
- unconvert
|
||||
- usestdlibvars
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*encoding/json.Encoder).Encode
|
||||
- (net/http.ResponseWriter).Write
|
||||
- fmt.Fprintf
|
||||
- fmt.Fprint
|
||||
- fmt.Fprintln
|
||||
misspell:
|
||||
locale: US
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
# Deprecations are tracked separately; some (e.g. gRPC dial options)
|
||||
# are kept intentionally for compatibility. Migrate them on their own.
|
||||
- -SA1019
|
||||
# Initialism/naming convention (Id->ID, Http->HTTP, ...). The remaining
|
||||
# offenders are exported identifiers (e.g. web.Id, web.DefaultId) whose
|
||||
# rename is a breaking API change; not worth it in a lint-bootstrap pass.
|
||||
- -ST1003
|
||||
exclusions:
|
||||
# Use golangci-lint's built-in sensible exclusions and skip generated code.
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# The protobuf code generator is a port of upstream protoc-gen-go and
|
||||
# keeps its structure; don't flag its unused legacy helpers.
|
||||
- path: cmd/protoc-gen-micro/generator/
|
||||
linters:
|
||||
- unused
|
||||
# Tests are held to a looser standard.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- bodyclose
|
||||
- errcheck
|
||||
# Demo/harness code: fire-and-forget calls are fine and `_ =` noise hurts
|
||||
# readability of examples.
|
||||
- path: (^|/)(examples|internal/harness)/
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
# Can be considered to be enabled
|
||||
- gochecknoinits
|
||||
- gochecknoglobals # RIP
|
||||
- dogsled
|
||||
- wrapcheck
|
||||
- paralleltest
|
||||
- ireturn
|
||||
- gomnd
|
||||
- goerr113
|
||||
- exhaustruct
|
||||
- containedctx
|
||||
- godox
|
||||
- forcetypeassert
|
||||
- gci
|
||||
- lll
|
||||
|
||||
issues:
|
||||
# List of regexps of issue texts to exclude, empty list by default.
|
||||
# But independently from this option we use default exclude patterns,
|
||||
# it can be disabled by `exclude-use-default: false`. To list all
|
||||
# excluded by default patterns execute `golangci-lint run --help`
|
||||
# exclude:
|
||||
# - package comment should be of the form "Package services ..." # revive
|
||||
# - ^ST1000 # ST1000: at least one file in a package should have a package comment (stylecheck)
|
||||
|
||||
# exclude-rules:
|
||||
# - path: internal/app/machined/pkg/system/services
|
||||
# linters:
|
||||
# - dupl
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- dupl
|
||||
- gosec
|
||||
- funlen
|
||||
- varnamelen
|
||||
- wsl
|
||||
|
||||
# Independently from option `exclude` we use default exclude patterns,
|
||||
# it can be disabled by this option. To list all
|
||||
# excluded by default patterns execute `golangci-lint run --help`.
|
||||
# Default value for this option is true.
|
||||
exclude-use-default: false
|
||||
|
||||
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
|
||||
max-issues-per-linter: 0
|
||||
|
||||
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
|
||||
max-same-issues: 0
|
||||
|
||||
# Show only new issues: if there are unstaged changes or untracked files,
|
||||
# only those changes are analyzed, else only changes in HEAD~ are analyzed.
|
||||
# It's a super-useful option for integration of golangci-lint into existing
|
||||
# large codebase. It's not practical to fix all existing issues at the moment
|
||||
# of integration: much better don't allow issues in new code.
|
||||
# Default is false.
|
||||
new: false
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
@@ -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.
|
||||
+3
-2
@@ -13,18 +13,19 @@ The AI-native major release. Breaking changes are listed first; everything
|
||||
else is additive. See the [v5 → v6 migration guide](internal/website/docs/guides/migration/v5-to-v6.md) — it's a small upgrade.
|
||||
|
||||
### Changed (breaking)
|
||||
- **Module path is now `go-micro.dev/v6`.** Update imports (`go-micro.dev/v5/...` → `go-micro.dev/v6/...`) and `go install go-micro.dev/v6/cmd/micro@latest`.
|
||||
- **Module path is now `go-micro.dev/v6`.** Update imports (`go-micro.dev/v5/...` → `go-micro.dev/v6/...`) and `go install go-micro.dev/v6/cmd/micro@v6`.
|
||||
- **TLS verification is on by default.** v5 skipped verification unless `MICRO_TLS_SECURE=true`; v6 verifies by default. `MICRO_TLS_SECURE` is removed — set `MICRO_TLS_INSECURE=true` (or call `tls.InsecureConfig()`) for self-signed/dev certs.
|
||||
- **`micro.NewService(name, opts...)` is the service constructor**, symmetric with `NewAgent`/`NewFlow`. `micro.New(name, opts...)` remains as a deprecated alias; the old name-less `micro.NewService(opts...)` form is removed (pass the name positionally). Generators emit the new form.
|
||||
- **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, `tasks/resubscribe`, `input-required` handoffs, and card discovery. (`gateway/a2a/`, `cmd/micro/a2a/`)
|
||||
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
|
||||
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
|
||||
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
|
||||
- **Pluggable agent memory & custom tools** — durable store-backed conversation memory by default, swappable via `AgentMemory`; register any function as a tool with `AgentTool`.
|
||||
- **Workflows (`micro.NewFlow`)** — event-driven orchestration that maps to Anthropic's workflow/agent split: an event triggers a deterministic step (or ordered durable steps), or dispatches to an agent with `FlowAgent`. (`flow/`)
|
||||
- **Flow loops (`FlowLoop`)** — a flow step that runs a body step repeatedly, carrying state across passes, until a stop condition is met or a hard iteration cap is hit. Stop on a code-defined predicate (`FlowUntil`) or let the model judge it done (`FlowUntilLLM` — the supervised "Ralph" loop); `FlowLoopMax` is the guardrail that guarantees termination, and `FlowOnIteration` reports progress. (`flow/loop.go`, `examples/flow-loop/`, guide)
|
||||
- **x402 payments** — opt-in per-call payments for tools via the x402 standard, with a pluggable facilitator and a consumer-side client + budget; the MCP gateway can advertise and require payment per tool. (`wrapper/x402/`, guide + blog)
|
||||
- **Scoped store state** — `store.Scope(s, database, table)` returns a store handle that confines every operation to a database/table without mutating the shared store (unlike `Init(Table(...))`, which is process-global and races between co-located components). Services, agents, and flows now each keep their state in their own table (`service/{name}`, `agent/{name}`, `flow/{name}`); the service path replaces the old `Init(store.Table(name))` global mutation with a scoped handle.
|
||||
- **Flow discovery & history CLI** — running flows now register in the registry as `type=flow` (and deregister on `Stop`), so they're discoverable like agents: `micro flow list` shows running flows, `micro flow runs <name>` shows a flow's durable run history from the store, and `micro agent history <name>` shows an agent's stored conversation. Live state comes from the registry; durable history from the scoped store.
|
||||
|
||||
@@ -125,20 +125,30 @@ Build compelling demos showing agents interacting with go-micro services in real
|
||||
| CLI Entry | `cmd/micro/main.go` |
|
||||
| MCP CLI | `cmd/micro/mcp/` |
|
||||
| Server (run/server) | `cmd/micro/server/server.go` |
|
||||
| Roadmap | `internal/docs/ROADMAP_2026.md` |
|
||||
| Status | `internal/docs/CURRENT_STATUS_SUMMARY.md` |
|
||||
| Roadmap | `ROADMAP.md` (full: `internal/website/docs/roadmap.md`) |
|
||||
| Status | `CHANGELOG.md` |
|
||||
| Changelog | `CHANGELOG.md` |
|
||||
| Docs Site | `internal/website/docs/` |
|
||||
|
||||
## Roadmap & Status Documents
|
||||
|
||||
- **[ROADMAP.md](ROADMAP.md)** - General framework roadmap
|
||||
- **[internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md)** - AI-native era roadmap with business model
|
||||
- **[internal/docs/CURRENT_STATUS_SUMMARY.md](internal/docs/CURRENT_STATUS_SUMMARY.md)** - Quick status overview
|
||||
- **[internal/docs/PROJECT_STATUS_2026.md](internal/docs/PROJECT_STATUS_2026.md)** - Detailed technical status
|
||||
- **[ROADMAP.md](ROADMAP.md)** - the single, current roadmap (agentic development + DX). Full version at `internal/website/docs/roadmap.md`.
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** - what shipped and when (the source of truth for status).
|
||||
- **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** - What changed and when
|
||||
|
||||
## Coordination with Codex
|
||||
|
||||
Go Micro is maintained by two AI tools — **Claude Code** (you) and **Codex** (its playbook is [CODEX.md](CODEX.md)) — plus the human maintainer, who routes work and owns every merge. To work side by side without collisions:
|
||||
|
||||
- **Lanes / branches.** You work on `claude/*` branches; Codex on `codex/*`. Never push to Codex's branch, and never have both agents committing the same branch at once.
|
||||
- **Base PRs on `master`; don't stack on Codex's in-flight branch.** If that base squash-merges, your commit gets orphaned (this happened — the #3007 fixes had to be re-landed). If the code you need isn't merged yet, wait for it, then branch off `master`. To improve an *open* Codex PR, fix it in place (once Codex is done with the branch, or via an `@codex` comment on the PR) rather than a separate stacked PR.
|
||||
- **One concern per PR.** Single-purpose PRs; don't bundle (e.g.) a feature with a docs change.
|
||||
- **Cross-review.** Review Codex's PRs before merge — mechanical fixes you can land yourself (based on `master`), but design/scope/positioning calls go to the human; don't silently rewrite Codex's intent. Codex reviews yours via `@codex review`.
|
||||
- **Dispatching Codex.** Start a task by commenting `@codex <instruction>` on an issue/PR (that issue/PR is its context). `@codex review` is reserved for review; any other instruction starts a *task*. It's consequential (spends a Codex task slot, pushes commits) and **serial** (one task at a time) — so dispatch one task at a time, only on the human's go-ahead, and never write a literal `@codex` in a comment unless you intend to trigger it (write "Codex" in prose otherwise).
|
||||
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass before merge. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
|
||||
- **Backlog = GitHub issues**, each a scoped, self-contained brief with acceptance criteria.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Key points:
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Codex Maintainer Playbook
|
||||
|
||||
Go Micro has six months of Codex access through OpenAI's Codex for Open Source
|
||||
program. Use it to increase maintainer throughput without changing the project's
|
||||
bar for review, tests, or design taste.
|
||||
|
||||
## Operating principles
|
||||
|
||||
1. **Humans set direction; Codex accelerates execution.** Maintainers choose the
|
||||
issue, constraints, and acceptance criteria. Codex drafts, investigates, and
|
||||
verifies.
|
||||
2. **Small, reviewable changes win.** Prefer focused PRs that can be understood
|
||||
in one sitting over large speculative rewrites.
|
||||
3. **Keep the contract green.** Every Codex-assisted change should preserve the
|
||||
CLI-first getting-started flow, the harnesses, `make test`, and `make lint`.
|
||||
4. **Document while coding.** If behavior changes, ask Codex to update examples,
|
||||
guides, and release notes in the same branch.
|
||||
5. **No blind merges.** Codex output is treated like any contributor output:
|
||||
reviewed by a maintainer, backed by tests, and checked for public API impact.
|
||||
|
||||
## Coordination with Claude Code
|
||||
|
||||
Go Micro is maintained by two AI tools — **Codex** (you) and **Claude Code** (its guide is [CLAUDE.md](CLAUDE.md)) — plus the human maintainer, who routes work and owns every merge.
|
||||
|
||||
- **Lanes / branches.** You work on `codex/*` branches; Claude Code on `claude/*`. Never push to a branch the other owns, and never have both agents on one branch at once.
|
||||
- **Base PRs on `master`.** Don't stack a PR on another agent's in-flight branch — if that base squash-merges, your changes can be orphaned. If the code you need isn't merged yet, wait, then branch off `master`. To improve a PR that hasn't merged, push to that PR's branch rather than opening a separate stacked PR — keep the change one mergeable unit.
|
||||
- **One concern per PR.** Keep each PR single-purpose so a reviewer can read it in one sitting; don't bundle unrelated changes (e.g. a feature plus a docs rebrand).
|
||||
- **Cross-review before merge.** Claude Code reviews your PRs; you review its with `@codex review`. A fresh pass from the other model catches what the author misses.
|
||||
- **Dispatch.** Maintainers (or Claude Code) start your tasks with `@codex <instruction>` on the relevant issue/PR — that's your context. `@codex review` is review; any other instruction is a *task*. You run one task at a time: take the current one to a clean, green PR before the next is dispatched.
|
||||
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass; never merge red. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
|
||||
- **Backlog = GitHub issues**, each a scoped brief with acceptance criteria.
|
||||
|
||||
## Best uses
|
||||
|
||||
### 1. PR review and triage
|
||||
|
||||
- Summarize a PR: changed surface area, public API impact, tests added or missing.
|
||||
- Ask for targeted review passes: concurrency, cancellation, security, backwards
|
||||
compatibility, docs drift, and examples.
|
||||
- Convert review findings into small patch suggestions or issue comments.
|
||||
|
||||
### 2. Issue reproduction
|
||||
|
||||
- Turn bug reports into failing tests or runnable reproduction scripts.
|
||||
- Minimize flakes by isolating registry, broker, store, transport, and AI-provider
|
||||
dependencies behind deterministic fakes where possible.
|
||||
- Attach the exact command that reproduces the failure to the issue.
|
||||
|
||||
### 3. Release support
|
||||
|
||||
- Draft changelog entries from merged commits, grouped by feature, fix, docs, and
|
||||
compatibility notes.
|
||||
- Check that `README.md`, `ROADMAP.md`, website docs, examples, and `CHANGELOG.md`
|
||||
agree before tagging.
|
||||
- Run dry-run release commands and summarize blockers.
|
||||
|
||||
### 4. Docs and examples
|
||||
|
||||
- Keep the 0→1 path current: scaffold, run, call, chat, inspect.
|
||||
- Keep the 0→hero example current: a realistic multi-agent system that exercises
|
||||
agents, services, flows, MCP, A2A, and observability.
|
||||
- Add runnable examples for new primitives before adding broad prose.
|
||||
|
||||
### 5. Hardening backlog
|
||||
|
||||
Use Codex to break roadmap items into small PRs, especially:
|
||||
|
||||
- cross-provider conformance scenarios for all supported AI providers;
|
||||
- timeout, cancellation, retry, and rate-limit behavior;
|
||||
- durable agent loops on top of the existing checkpoint model;
|
||||
- streaming across `ai.Stream` and A2A;
|
||||
- agent run metadata mapped to OpenTelemetry spans.
|
||||
|
||||
## Suggested weekly loop
|
||||
|
||||
1. Pick one maintenance lane: reviews, bugs, release prep, docs, or hardening.
|
||||
2. Ask Codex for a branch-sized plan with acceptance criteria and test commands.
|
||||
3. Have Codex implement the smallest valuable slice.
|
||||
4. Run the relevant checks locally and in CI.
|
||||
5. Review the diff as maintainer-owned code, then merge or send it back.
|
||||
6. Record any recurring prompt, check, or failure mode in this playbook.
|
||||
|
||||
|
||||
## First two weeks
|
||||
|
||||
Do not start with a giant feature. Start by making Codex pay rent on maintenance
|
||||
work that is already on the roadmap and easy to review.
|
||||
|
||||
### Day 1: set up the review loop
|
||||
|
||||
1. Pick three recent PRs or commits: one feature, one bug fix, and one docs-only
|
||||
change.
|
||||
2. Ask Codex to review each using the PR review template below.
|
||||
3. Compare Codex findings with maintainer judgment. Keep the checks that found
|
||||
real issues; delete the noisy ones.
|
||||
4. Turn the final review prompt into a saved project note or issue comment
|
||||
template.
|
||||
|
||||
Success means Codex can produce a useful first-pass review in under ten minutes
|
||||
without blocking a maintainer on false positives.
|
||||
|
||||
### Days 2-3: make bugs reproducible
|
||||
|
||||
1. Pick one open bug or flaky area.
|
||||
2. Ask Codex for a failing test only. Do not allow a fix in the first pass.
|
||||
3. Review the test for whether it captures the real contract.
|
||||
4. In a second branch, ask Codex to fix the failure with the smallest patch.
|
||||
|
||||
Success means every accepted bug fix starts with a regression test or deterministic
|
||||
harness case.
|
||||
|
||||
### Days 4-5: audit the getting-started contract
|
||||
|
||||
Run through the 0→1 path from a clean checkout and ask Codex to patch only the
|
||||
first broken or confusing step. The target is not new prose; it is a runnable
|
||||
path that works exactly as documented.
|
||||
|
||||
Candidate checks:
|
||||
|
||||
```sh
|
||||
make test
|
||||
make harness
|
||||
make lint
|
||||
go run ./examples/hello-world
|
||||
go run ./internal/harness/universe
|
||||
```
|
||||
|
||||
### Week 2: choose one roadmap slice
|
||||
|
||||
Pick one hardening item and break it into PRs that each land independently. The
|
||||
best first slice is usually test infrastructure, not product code.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. **Provider conformance skeleton**: define one deterministic agent scenario and
|
||||
gate real-provider runs on credentials.
|
||||
2. **Cancellation audit**: trace `context.Context` propagation through one package
|
||||
at a time.
|
||||
3. **Docs drift audit**: compare `README.md`, `ROADMAP.md`, website docs, and
|
||||
examples for one shipped feature.
|
||||
4. **Release checklist dry run**: have Codex build a release-blocker list from the
|
||||
diff since the previous tag.
|
||||
|
||||
## Standing task queue
|
||||
|
||||
Keep Codex busy on tasks with clear acceptance criteria:
|
||||
|
||||
| Priority | Task | Acceptance criteria |
|
||||
| --- | --- | --- |
|
||||
| P0 | PR first-pass review | Summary, risks, required changes, and exact verification commands. |
|
||||
| P0 | Bug reproduction | A failing test or harness case committed before the fix. |
|
||||
| P0 | 0→1 docs check | Fresh-checkout commands work as written or a patch fixes the first break. |
|
||||
| P1 | Cross-provider conformance | One scenario runs against fakes by default and real providers when keys exist. |
|
||||
| P1 | Cancellation hardening | Tests prove timeout/cancel behavior for the touched package. |
|
||||
| P1 | Release audit | Changelog, docs, examples, and migration notes agree before tagging. |
|
||||
| P2 | Example polish | Example is runnable, linked from docs, and covered by a lightweight check. |
|
||||
|
||||
## What not to use Codex for yet
|
||||
|
||||
- Broad rewrites without a failing test, benchmark, or public design note.
|
||||
- Public API changes before a maintainer writes the compatibility story.
|
||||
- Large generated docs that nobody has run.
|
||||
- Provider-specific behavior that is not checked against the shared `ai.Model`
|
||||
contract.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
### PR review
|
||||
|
||||
```text
|
||||
Review this PR for Go Micro. Focus on public API compatibility, cancellation and
|
||||
context propagation, concurrency safety, tests, and docs drift. Return: summary,
|
||||
risks, required changes, optional improvements, and exact commands to verify.
|
||||
```
|
||||
|
||||
### Bug reproduction
|
||||
|
||||
```text
|
||||
Reproduce this issue in the smallest Go test or harness change possible. Do not
|
||||
fix it yet. Explain the failing path and provide the exact command that fails.
|
||||
```
|
||||
|
||||
### Branch implementation
|
||||
|
||||
```text
|
||||
Implement the smallest branch that satisfies this issue. Keep the API compatible
|
||||
unless explicitly required, update docs/examples when behavior changes, and run
|
||||
`make test`, `make harness`, and `make lint` or explain any environment blocker.
|
||||
```
|
||||
|
||||
### Release audit
|
||||
|
||||
```text
|
||||
Audit this release branch. Compare CHANGELOG, README, ROADMAP, website docs, and
|
||||
examples against the diff since the last tag. List inconsistencies, missing
|
||||
migration notes, and checks to run before tagging.
|
||||
```
|
||||
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
|
||||
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
|
||||
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
|
||||
|
||||
.PHONY: test test-race test-coverage harness lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
.PHONY: test test-race test-coverage harness provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -18,6 +18,8 @@ help:
|
||||
@echo " make test-race - Run tests with race detector"
|
||||
@echo " make test-coverage - Run tests with coverage"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make harness - Run deterministic end-to-end harnesses"
|
||||
@echo " make provider-conformance - Run harnesses against configured live providers"
|
||||
@echo " make fmt - Format code"
|
||||
@echo " make install-tools - Install development tools"
|
||||
@echo " make proto - Generate protobuf code"
|
||||
@@ -45,7 +47,12 @@ 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.
|
||||
provider-conformance:
|
||||
go run ./internal/harness/provider-conformance
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
|
||||
Go Micro is a framework for building agents and services in Go.
|
||||
Go Micro is an **agent harness** and service framework for Go.
|
||||
|
||||
Build an agent and it gets a model, memory, and tools, manages your services, and is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and they register, discover each other, and every endpoint is automatically an AI-callable tool. Orchestrate the deterministic parts with flows. Agents, services, and flows are all Go code — the same primitives, the same deployment — 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
|
||||
|
||||
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
|
||||
|
||||
<a href="https://go-micro.dev/blog/29"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
|
||||
|
||||
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
|
||||
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
|
||||
|
||||
## Commercial Support
|
||||
|
||||
Running Go Micro in production, or building on it and want help? Paid **support, consulting, training, and retainers** are available directly from the maintainer — and they're what keep the project maintained. See [**Support**](SUPPORT.md) for the tiers, or [open a request](https://github.com/micro/go-micro/issues/new?template=commercial_support.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Why an Agent Harness](#why-an-agent-harness)
|
||||
- [Writing Services](#writing-services)
|
||||
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
|
||||
- [Features](#features)
|
||||
@@ -23,6 +32,7 @@ Build an agent and it gets a model, memory, and tools, manages your services, an
|
||||
- [Data Model](#data-model)
|
||||
- [AI Providers](#ai-providers)
|
||||
- [Examples](#examples)
|
||||
- [Commercial Support](#commercial-support)
|
||||
- [Docs](#docs)
|
||||
|
||||
## Quick Start
|
||||
@@ -34,7 +44,7 @@ Install the CLI:
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
# Or with Go
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
### Fastest start — no API key
|
||||
@@ -91,7 +101,7 @@ Then talk to your services from the console:
|
||||
→ task_Task_Create({"title":"Write code","project_id":"p1..."})
|
||||
→ task_Task_Create({"title":"Ship it","project_id":"p1..."})
|
||||
|
||||
Created Work category and added 'Finish report' task to it.
|
||||
Created project Launch and added three tasks to it.
|
||||
```
|
||||
|
||||
When you need a capability that doesn't exist, the agent generates a new service mid-conversation:
|
||||
@@ -109,6 +119,20 @@ When you need a capability that doesn't exist, the agent generates a new service
|
||||
|
||||
Edit the generated code by hand at any time — re-running preserves your changes. [Read more](https://go-micro.dev/blog/13).
|
||||
|
||||
## Why an Agent Harness
|
||||
|
||||
The first wave of agent frameworks helped developers put a model in a loop. The next problem is operating that loop: connecting it to real tools, scoping what it can touch, preserving state, routing work to specialists, recovering from failures, observing what happened, and letting other agents call it. That is harness work.
|
||||
|
||||
Go Micro's answer is to make the harness the same thing you already deploy:
|
||||
|
||||
- **Tools are services** — endpoint metadata becomes tool schema; RPC executes the call.
|
||||
- **Agents are services** — they register, discover, load-balance, and expose `Agent.Chat`.
|
||||
- **Workflows are durable code paths** — use flows when the path is known; dispatch to agents when it is not.
|
||||
- **Safety lives at execution** — `MaxSteps`, `LoopLimit`, `ApproveTool`, and tool wrappers run where actions happen.
|
||||
- **Interop is built in** — MCP for tools, A2A for agents, x402 for paid tools.
|
||||
|
||||
Use Go Micro when the agent has to operate a system, not just answer a prompt.
|
||||
|
||||
## Writing Services
|
||||
|
||||
Under the hood, a service is a struct with methods. Doc comments and `@example` tags become tool descriptions for AI agents automatically.
|
||||
@@ -117,6 +141,8 @@ Under the hood, a service is a struct with methods. Doc comments and `@example`
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
)
|
||||
|
||||
@@ -191,7 +217,7 @@ micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
|
||||
|
||||
### Plan & Delegate
|
||||
|
||||
Every agent gets two built-in capabilities, exposed as tools — no extra setup, no harness:
|
||||
Every agent gets two built-in harness capabilities, exposed as tools — no extra setup or separate graph runtime:
|
||||
|
||||
- **`plan`** — for multi-step work, the agent records an ordered plan in its store-backed memory and stays oriented across turns.
|
||||
- **`delegate`** — the agent hands a self-contained subtask to another agent. If a registered agent already owns the relevant services, the hand-off goes over RPC to that agent; otherwise a focused, short-lived sub-agent is created for the subtask with its own isolated context.
|
||||
@@ -211,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) {
|
||||
@@ -221,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)
|
||||
|
||||
@@ -229,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).
|
||||
|
||||
+57
-160
@@ -1,182 +1,79 @@
|
||||
# Go Micro Roadmap
|
||||
|
||||
This roadmap outlines the planned features and improvements for Go Micro. Community feedback and contributions are welcome!
|
||||
Go Micro is an **agent harness** and service framework for Go. A harness is the
|
||||
runtime around an agent — the tools, memory, guardrails, workflows, state,
|
||||
discovery, and protocols it needs to operate a system rather than just answer a
|
||||
prompt. An agent is a distributed system — it discovers services, calls them,
|
||||
holds state, and recovers from failure — so the harness is the runtime services
|
||||
already have, and building an agent is building a service. The roadmap has two
|
||||
jobs: make **agentic development** excellent, and make the **developer experience**
|
||||
around it excellent.
|
||||
|
||||
> **See [internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md) for the AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
|
||||
The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micro.dev/docs/roadmap)**
|
||||
([source](internal/website/docs/roadmap.md)). The highlights:
|
||||
|
||||
## Current Focus (Q1 2026) - COMPLETE
|
||||
## Where we are (v6)
|
||||
|
||||
### Documentation & Developer Experience
|
||||
- [x] Modernize documentation structure
|
||||
- [x] Add learn-by-example guides
|
||||
- [x] Update issue templates
|
||||
- [x] MCP integration documentation
|
||||
- [x] Agent playground and MCP tools registry
|
||||
- [ ] Create video tutorials
|
||||
- [ ] Interactive documentation site
|
||||
- [ ] Plugin discovery dashboard
|
||||
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
|
||||
flows, the MCP and A2A gateways (both directions, including A2A streaming,
|
||||
push notifications, and multi-turn continuation), x402 paid tools, secure by
|
||||
default.
|
||||
|
||||
### AI & Model Integration
|
||||
- [x] AI package with provider abstraction (`ai.Model` interface)
|
||||
- [x] Anthropic Claude provider (`ai/anthropic`)
|
||||
- [x] OpenAI GPT provider (`ai/openai`)
|
||||
- [x] Tool execution with auto-calling support
|
||||
- [x] Streaming support via `ai.Stream`
|
||||
## Principles
|
||||
|
||||
### Observability
|
||||
- [ ] OpenTelemetry native support
|
||||
- [ ] Auto-instrumentation for handlers
|
||||
- [ ] Metrics export standardization
|
||||
- [ ] Distributed tracing examples
|
||||
- [ ] Integration with popular observability platforms
|
||||
1. Build into what people run, never a separate product (no hosted platform, no
|
||||
enterprise edition, no VC).
|
||||
2. CLI-first — the CLI is the experience; UI must earn its place, never bloat.
|
||||
3. The getting-started flow is a contract: *0→1* (scaffold → run → call) and
|
||||
*0→hero* (a working multi-agent system) must always work and are verified on
|
||||
every change.
|
||||
4. Interaction matters as much as running — chatting with agents, inspecting runs
|
||||
and history, end to end.
|
||||
5. Battle-tested: works across every provider, fails safely, observable.
|
||||
|
||||
### Developer Tools
|
||||
- [x] `micro run` with hot reload and unified gateway
|
||||
- [x] `micro deploy` with SSH + systemd deployment
|
||||
- [x] `micro mcp` command suite (serve, list, test, docs, export)
|
||||
- [ ] `micro dev` with enhanced hot reload
|
||||
- [ ] Service templates (`micro new --template`)
|
||||
- [ ] Better error messages with suggestions
|
||||
- [ ] Debug tooling improvements
|
||||
- [ ] VS Code extension for Go Micro
|
||||
## Now — hardening
|
||||
|
||||
## Q2 2026
|
||||
- **Cross-provider conformance** — the same agent scenario across all seven
|
||||
providers, gated on keys, on a schedule.
|
||||
- **Failure & resilience** — timeouts, rate limits, cancellation, deadline/context
|
||||
propagation, retry/backoff.
|
||||
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
|
||||
|
||||
### Production Readiness
|
||||
- [x] Health check standardization
|
||||
- [x] Graceful shutdown improvements
|
||||
- [ ] Resource cleanup best practices
|
||||
- [ ] Load testing framework integration
|
||||
- [ ] Performance benchmarking suite
|
||||
## Next — agentic depth
|
||||
|
||||
### Cloud Native
|
||||
- [ ] Kubernetes operator
|
||||
- [ ] Helm charts for common setups
|
||||
- [ ] Service mesh integration guides (Istio, Linkerd)
|
||||
- [ ] Cloud provider quickstarts (AWS, GCP, Azure)
|
||||
- [ ] Multi-cluster patterns
|
||||
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
|
||||
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
|
||||
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
|
||||
|
||||
### Security
|
||||
- [x] Bearer token authentication for MCP
|
||||
- [x] Per-tool scope enforcement
|
||||
- [x] Audit logging
|
||||
- [x] Rate limiting
|
||||
- [ ] mTLS by default option
|
||||
- [ ] Secret management integration (Vault, AWS Secrets Manager)
|
||||
- [ ] RBAC improvements
|
||||
- [ ] Security audit and hardening
|
||||
- [ ] CVE scanning and response process
|
||||
## Later
|
||||
|
||||
## Q3 2026
|
||||
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
|
||||
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
|
||||
handoffs.
|
||||
|
||||
### Plugin Ecosystem
|
||||
- [ ] Plugin marketplace/registry
|
||||
- [ ] Plugin quality standards
|
||||
- [ ] Community plugin contributions
|
||||
- [ ] Plugin compatibility matrix
|
||||
- [ ] Auto-discovery of available plugins
|
||||
## Developer experience (ongoing)
|
||||
|
||||
### Streaming & Async
|
||||
- [ ] Improved streaming support
|
||||
- [x] Server-sent events (SSE) support (via MCP gateway)
|
||||
- [ ] WebSocket plugin
|
||||
- [ ] Event sourcing patterns
|
||||
- [ ] CQRS examples
|
||||
- A seamless CLI inner loop (scaffold → run → chat → inspect → deploy); UI
|
||||
discipline (trim what isn't great); a maintained real-world example that doubles
|
||||
as the 0→hero reference; docs kept in lockstep with the code.
|
||||
|
||||
### Testing
|
||||
- [ ] Mock generation tooling
|
||||
- [ ] Integration test helpers
|
||||
- [ ] Contract testing support
|
||||
- [ ] Chaos engineering examples
|
||||
- [ ] E2E testing framework
|
||||
## How it's sustained
|
||||
|
||||
## Q4 2026
|
||||
The framework is the product, funded by sponsorship from those who run it — not a
|
||||
hosted service, enterprise tier, or venture funding. See
|
||||
[the v6 story](https://go-micro.dev/blog/27).
|
||||
|
||||
### Performance
|
||||
- [ ] Connection pooling optimizations
|
||||
- [ ] Zero-allocation paths
|
||||
- [ ] gRPC performance improvements
|
||||
- [ ] Caching strategies guide
|
||||
- [ ] Performance profiling tools
|
||||
## Contributing & feedback
|
||||
|
||||
### Developer Productivity
|
||||
- [ ] Code generation improvements
|
||||
- [ ] Better IDE support
|
||||
- [ ] Debugging tools
|
||||
- [ ] Migration automation tools
|
||||
- [ ] Upgrade helpers
|
||||
Pick an item, open an issue to discuss the approach, and submit a PR. Or join the
|
||||
[Discord](https://discord.gg/WeMU5AGxD). Include tests, run `make test` and
|
||||
`make lint`.
|
||||
|
||||
### Community
|
||||
- [ ] Regular blog posts and case studies
|
||||
- [ ] Community spotlight program
|
||||
- [ ] Contribution rewards
|
||||
- [ ] Monthly community calls
|
||||
- [ ] Conference presence
|
||||
## Version support
|
||||
|
||||
## Long-term Vision
|
||||
- **v6** — active development (current).
|
||||
- **v5** — security fixes only.
|
||||
- **v4 and earlier** — end of life.
|
||||
|
||||
### Core Framework
|
||||
- Maintain backward compatibility (Go Micro v5+)
|
||||
- Progressive disclosure of complexity
|
||||
- Best-in-class developer experience
|
||||
- Production-grade reliability
|
||||
- Comprehensive plugin ecosystem
|
||||
|
||||
### Ecosystem Goals
|
||||
- 100+ production deployments documented
|
||||
- 50+ community plugins
|
||||
- Active contributor community
|
||||
- Regular releases (monthly patches, quarterly features)
|
||||
- Comprehensive benchmarks vs alternatives
|
||||
|
||||
### Differentiation
|
||||
- **Batteries included, fully swappable** - Start simple, scale complex
|
||||
- **Zero-config local development** - No infrastructure required to start
|
||||
- **AI-native by default** - Every service is an MCP tool automatically
|
||||
- **Plugin ecosystem in-repo** - No version compatibility hell
|
||||
- **Progressive complexity** - Learn as you grow
|
||||
- **Cloud-native first** - Built for Kubernetes and containers
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### High Priority Areas
|
||||
1. Documentation improvements (guides, tutorials)
|
||||
2. Multi-protocol MCP support (WebSocket, gRPC)
|
||||
3. Agent SDK integrations (LlamaIndex, AutoGPT)
|
||||
4. OpenTelemetry integration
|
||||
5. Kubernetes operator and Helm charts
|
||||
|
||||
### How to Contribute
|
||||
- Pick an item from the roadmap
|
||||
- Open an issue to discuss approach
|
||||
- Submit a PR with implementation
|
||||
- Help review others' contributions
|
||||
|
||||
## Feedback
|
||||
|
||||
Have suggestions for the roadmap?
|
||||
|
||||
- Open a [feature request](.github/ISSUE_TEMPLATE/feature_request.md)
|
||||
- Start a discussion in GitHub Discussions
|
||||
- Comment on existing roadmap issues
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
We follow semantic versioning:
|
||||
- Major versions (v5 → v6): Breaking changes
|
||||
- Minor versions (v5.3 → v5.4): New features, backward compatible
|
||||
- Patch versions (v5.3.0 → v5.3.1): Bug fixes, no API changes
|
||||
|
||||
## Support Timeline
|
||||
|
||||
- v5: Active development (current)
|
||||
- v4: Security fixes only (until v6 release)
|
||||
- v3: End of life
|
||||
|
||||
---
|
||||
|
||||
Last updated: March 2026
|
||||
|
||||
This roadmap is subject to change based on community needs and priorities.
|
||||
Major versions (v5 → v6) carry breaking changes; minors are backward-compatible.
|
||||
See the [v5 → v6 migration guide](https://go-micro.dev/docs/guides/migration/v5-to-v6).
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Support
|
||||
|
||||
Go Micro is free and open source. There are two ways to get help: the community, and commercial support.
|
||||
|
||||
## Community support (free)
|
||||
|
||||
- **Documentation** — https://go-micro.dev/docs
|
||||
- **Examples** — https://github.com/micro/go-micro/tree/master/examples
|
||||
- **Bugs & features** — https://github.com/micro/go-micro/issues
|
||||
- **Questions** — open a [Question](https://github.com/micro/go-micro/issues/new?template=question.md) issue
|
||||
|
||||
Community support is best-effort, from maintainers and contributors, with no response-time guarantees.
|
||||
|
||||
## Commercial support
|
||||
|
||||
If you're running Go Micro in production — or building agents and services on it and want a hand — paid support and consulting are available directly from the maintainer. This is what keeps the project maintained.
|
||||
|
||||
| Tier | For | What you get | How |
|
||||
|------|-----|--------------|-----|
|
||||
| **Community** | Everyone | Docs, examples, issues — best-effort | Free |
|
||||
| **Sponsor** | Individuals & companies who rely on Go Micro | Back ongoing development; your name/logo in the README and on the site; a voice in priorities | [GitHub Sponsors](https://github.com/sponsors/asim) |
|
||||
| **Support** | Teams running Go Micro in production | Priority responses, a direct line to the maintainer, prioritized bug fixes, upgrade & integration help | [Open a request](#get-in-touch) |
|
||||
| **Consulting** | Teams building on Go Micro | Hands-on integration, architecture & agent-design review, training & onboarding, sponsored features | [Open a request](#get-in-touch) |
|
||||
|
||||
Recurring amounts are set on the [Sponsors page](https://github.com/sponsors/asim); support and consulting are scoped and quoted per engagement.
|
||||
|
||||
## Get in touch
|
||||
|
||||
Open a [**Commercial Support / Consulting**](https://github.com/micro/go-micro/issues/new?template=commercial_support.md) request — tell us what you're building, what you need, and your timeline, and we'll follow up. For anything you'd rather not discuss in public, become a [sponsor](https://github.com/sponsors/asim) and message privately.
|
||||
+155
-8
@@ -20,8 +20,10 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
@@ -41,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
|
||||
@@ -51,6 +54,12 @@ type Response struct {
|
||||
Reply string
|
||||
ToolCalls []ai.ToolCall
|
||||
Agent string
|
||||
|
||||
// RunID correlates this Ask with tool calls, trace spans, and the
|
||||
// persisted run timeline. ParentID is set when this response belongs
|
||||
// to a delegated sub-agent run.
|
||||
RunID string
|
||||
ParentID string
|
||||
}
|
||||
|
||||
type agentImpl struct {
|
||||
@@ -72,6 +81,22 @@ type agentImpl struct {
|
||||
// calls counts identical tool calls (name+args) in the current Ask,
|
||||
// for LoopLimit.
|
||||
calls map[string]int
|
||||
|
||||
// runID correlates the tool calls of the current Ask; parentRunID is
|
||||
// the run that delegated to this one (set on ephemeral sub-agents).
|
||||
// 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
|
||||
|
||||
// currentRun points at the checkpoint record for the Ask currently
|
||||
// holding mu. Tool execution updates it so resumed runs can reuse
|
||||
// completed tool results without replaying side effects.
|
||||
currentRun *flow.Run
|
||||
}
|
||||
|
||||
// New creates a new Agent.
|
||||
@@ -123,6 +148,9 @@ 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.model != nil {
|
||||
a.model = a.tracedModel(a.model)
|
||||
}
|
||||
|
||||
// Memory is pluggable. Use the configured one, otherwise the default
|
||||
// store-backed memory — except ephemeral sub-agents, which keep an
|
||||
@@ -132,6 +160,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)
|
||||
}
|
||||
@@ -152,6 +182,42 @@ 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(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -159,6 +225,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)
|
||||
@@ -167,16 +237,70 @@ 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
|
||||
|
||||
resp, err := a.model.Generate(ctx, &ai.Request{
|
||||
// Correlate this run's tool calls and surface lineage to wrappers.
|
||||
a.runID = runID
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
run := a.newCheckpointRun(runID, message, parentRunID, existing)
|
||||
a.currentRun = &run
|
||||
defer func() { a.currentRun = nil }()
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
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()
|
||||
if a.currentRun != nil {
|
||||
run.Steps = a.currentRun.Steps
|
||||
}
|
||||
_ = 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)
|
||||
if a.pause.Tool == toolHumanInput {
|
||||
run.State.Stage = agentInputStep
|
||||
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
|
||||
}
|
||||
run.Steps[0].Status = "paused"
|
||||
run.Steps[0].Error = a.pause.Message
|
||||
run.Steps[0].Result = a.pause.Tool
|
||||
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)
|
||||
@@ -193,22 +317,44 @@ 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,
|
||||
}, nil
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
}
|
||||
run.Status = "done"
|
||||
run.State.Stage = ""
|
||||
if b, marshalErr := json.Marshal(res); marshalErr == nil {
|
||||
run.State.Data = b
|
||||
}
|
||||
if a.currentRun != nil {
|
||||
run.Steps = a.currentRun.Steps
|
||||
}
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
||||
}
|
||||
run.Steps[0].Status = "done"
|
||||
run.Steps[0].Attempts++
|
||||
run.Steps[0].Result = reply
|
||||
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{
|
||||
@@ -229,6 +375,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",
|
||||
@@ -236,7 +383,7 @@ func (a *agentImpl) Run() error {
|
||||
}),
|
||||
)
|
||||
|
||||
pb.RegisterAgentHandler(a.server, a)
|
||||
_ = pb.RegisterAgentHandler(a.server, a)
|
||||
|
||||
if err := a.server.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
@@ -248,13 +395,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)
|
||||
|
||||
+85
-14
@@ -20,8 +20,9 @@ import (
|
||||
// the discovered service tools. There is no separate harness or graph:
|
||||
// the LLM calls them like any other tool.
|
||||
const (
|
||||
toolPlan = "plan"
|
||||
toolDelegate = "delegate"
|
||||
toolPlan = "plan"
|
||||
toolDelegate = "delegate"
|
||||
toolHumanInput = "request_input"
|
||||
)
|
||||
|
||||
// builtinTools returns the tool definitions exposed to the model in
|
||||
@@ -41,6 +42,18 @@ func builtinTools() []ai.Tool {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: toolHumanInput,
|
||||
OriginalName: toolHumanInput,
|
||||
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
|
||||
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
|
||||
Properties: map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The specific question, decision, or instruction needed from the human operator.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: toolDelegate,
|
||||
OriginalName: toolDelegate,
|
||||
@@ -70,7 +83,7 @@ func builtinTools() []ai.Tool {
|
||||
//
|
||||
// Configure it with the same options as an Agent (Name, Provider,
|
||||
// WithStore, WithRegistry, WithClient, ...); these back plan's memory
|
||||
// and delegate's RPC/sub-agent behaviour.
|
||||
// and delegate's RPC/sub-agent behavior.
|
||||
func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input map[string]any) (result any, content string, ok bool)) {
|
||||
a := &agentImpl{opts: newOptions(opts...)}
|
||||
handle = func(name string, input map[string]any) (any, string, bool) {
|
||||
@@ -78,8 +91,11 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
|
||||
case toolPlan:
|
||||
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
case toolHumanInput:
|
||||
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
case toolDelegate:
|
||||
r := a.handleDelegate(ai.ToolCall{Name: name, Input: input})
|
||||
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
|
||||
return r.Value, r.Content, true
|
||||
}
|
||||
return nil, "", false
|
||||
@@ -102,18 +118,36 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
|
||||
// Innermost first: base, then guardrails (approve → loop → step →
|
||||
// plan), then developer wrappers outermost. Wrapping reverses order,
|
||||
// so the result runs plan → step → loop → approve → base.
|
||||
// so the result runs plan → step → loop → approve → checkpoint → base.
|
||||
h := a.baseHandler()
|
||||
h = a.checkpointToolWrap(h)
|
||||
h = a.approveWrap(h)
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
@@ -128,8 +162,11 @@ func (a *agentImpl) baseHandler() ai.ToolHandler {
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
|
||||
}
|
||||
}
|
||||
if call.Name == toolHumanInput {
|
||||
return a.handleHumanInput(call)
|
||||
}
|
||||
if call.Name == toolDelegate {
|
||||
return a.handleDelegate(call)
|
||||
return a.handleDelegate(ctx, call)
|
||||
}
|
||||
return rpc(ctx, call)
|
||||
}
|
||||
@@ -152,7 +189,7 @@ func (a *agentImpl) stepWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if a.opts.MaxSteps > 0 {
|
||||
a.steps++
|
||||
if a.steps > a.opts.MaxSteps {
|
||||
return errResult(call.ID, fmt.Sprintf(
|
||||
return refused(call.ID, ai.RefusedMaxSteps, fmt.Sprintf(
|
||||
"step limit reached (%d). Do not call any more tools; stop and summarize what you have so far.",
|
||||
a.opts.MaxSteps))
|
||||
}
|
||||
@@ -173,7 +210,7 @@ func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
fp := call.Name + ":" + string(args)
|
||||
a.calls[fp]++
|
||||
if a.calls[fp] > a.opts.LoopLimit {
|
||||
return errResult(call.ID, fmt.Sprintf(
|
||||
return refused(call.ID, ai.RefusedLoop, fmt.Sprintf(
|
||||
"loop detected: you have already called %q with the same arguments %d times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.",
|
||||
call.Name, a.opts.LoopLimit))
|
||||
}
|
||||
@@ -183,6 +220,16 @@ func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
}
|
||||
|
||||
// approveWrap gates each action before it runs (ApproveTool).
|
||||
type approvalPause struct {
|
||||
Tool string
|
||||
Message string
|
||||
}
|
||||
|
||||
type inputPause struct {
|
||||
OriginalMessage string `json:"original_message"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Approve != nil {
|
||||
@@ -191,7 +238,8 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if reason != "" {
|
||||
msg += ": " + reason
|
||||
}
|
||||
return errResult(call.ID, msg)
|
||||
a.pause = &approvalPause{Tool: call.Name, Message: msg}
|
||||
return refused(call.ID, ai.RefusedApproval, msg)
|
||||
}
|
||||
}
|
||||
return next(ctx, call)
|
||||
@@ -205,15 +253,26 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
||||
if err != nil {
|
||||
return errResult(call.ID, "invalid plan: "+err.Error())
|
||||
}
|
||||
a.stateStore().Write(&store.Record{Key: planKey, Value: data})
|
||||
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
}
|
||||
|
||||
// handleHumanInput records that the model needs operator input before it can continue.
|
||||
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
||||
prompt, _ := call.Input["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
prompt = "human input required"
|
||||
}
|
||||
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
|
||||
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
|
||||
}
|
||||
|
||||
// handleDelegate hands a subtask to another agent. Delegate-first:
|
||||
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
||||
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
||||
// the subtask, and its reply returned.
|
||||
func (a *agentImpl) handleDelegate(call ai.ToolCall) ai.ToolResult {
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
input := call.Input
|
||||
task, _ := input["task"].(string)
|
||||
if task == "" {
|
||||
@@ -223,7 +282,7 @@ func (a *agentImpl) handleDelegate(call ai.ToolCall) ai.ToolResult {
|
||||
|
||||
// An external agent on another framework, addressed by A2A URL.
|
||||
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
|
||||
reply, err := a2a.NewClient(to).Send(context.Background(), task)
|
||||
reply, err := a2a.NewClient(to).Send(ctx, task)
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
|
||||
}
|
||||
@@ -234,7 +293,7 @@ func (a *agentImpl) handleDelegate(call ai.ToolCall) ai.ToolResult {
|
||||
|
||||
// Delegate-first: an existing agent that owns the domain handles it.
|
||||
if to != "" && a.isAgent(to) {
|
||||
reply, err := a.callAgentRPC(context.Background(), to, task)
|
||||
reply, err := a.callAgentRPC(ctx, to, task)
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
|
||||
}
|
||||
@@ -260,9 +319,12 @@ func (a *agentImpl) handleDelegate(call ai.ToolCall) ai.ToolResult {
|
||||
WithRegistry(a.opts.Registry),
|
||||
WithClient(a.opts.Client),
|
||||
WithStore(a.opts.Store),
|
||||
TraceProvider(a.opts.TraceProvider),
|
||||
)
|
||||
// Record lineage so the sub-agent's tool calls carry this run as parent.
|
||||
sub.parentRunID = a.runID
|
||||
|
||||
resp, err := sub.Ask(context.Background(), task)
|
||||
resp, err := sub.Ask(ctx, task)
|
||||
if err != nil {
|
||||
return errResult(call.ID, "sub-agent: "+err.Error())
|
||||
}
|
||||
@@ -327,3 +389,12 @@ func errResult(id, msg string) ai.ToolResult {
|
||||
b, _ := json.Marshal(m)
|
||||
return ai.ToolResult{ID: id, Value: m, Content: string(b)}
|
||||
}
|
||||
|
||||
// refused is an error result a guardrail returns, tagged with a structured
|
||||
// reason (ai.Refused*) so a tool wrapper can react to it without parsing
|
||||
// the message.
|
||||
func refused(id, reason, msg string) ai.ToolResult {
|
||||
r := errResult(id, msg)
|
||||
r.Refused = reason
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
|
||||
func TestBuiltinTools(t *testing.T) {
|
||||
tools := builtinTools()
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 2", len(tools))
|
||||
if len(tools) != 3 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 3", len(tools))
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
names[tl.Name] = true
|
||||
}
|
||||
if !names[toolPlan] || !names[toolDelegate] {
|
||||
t.Errorf("builtin tools = %v, want plan and delegate", names)
|
||||
if !names[toolPlan] || !names[toolDelegate] || !names[toolHumanInput] {
|
||||
t.Errorf("builtin tools = %v, want plan, request_input, and delegate", names)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ func TestBuiltinsAccessor(t *testing.T) {
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
)
|
||||
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 2", len(tools))
|
||||
if len(tools) != 3 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 3", len(tools))
|
||||
}
|
||||
|
||||
// A name that isn't a built-in falls through (ok == false).
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
const (
|
||||
agentAskStep = "ask"
|
||||
agentApprovalStep = "approval"
|
||||
agentInputStep = "input-required"
|
||||
)
|
||||
|
||||
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
|
||||
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 = ""
|
||||
run.Steps[0].Result = ""
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Resume returns the response for a checkpointed agent run. Completed runs are
|
||||
// returned from the checkpoint without calling the model or replaying tool
|
||||
// calls; failed or in-progress runs continue from the saved input message.
|
||||
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resume(ctx, runID)
|
||||
}
|
||||
|
||||
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
}
|
||||
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" {
|
||||
if run.State.Stage == agentInputStep {
|
||||
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
|
||||
}
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// ResumeInput resumes a checkpointed agent run that paused via the built-in
|
||||
// request_input tool. The supplied input is appended to the original request so
|
||||
// the same run can continue with durable checkpoint and completed tool history.
|
||||
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resumeInput(ctx, runID, input)
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
}
|
||||
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent run %s not found", runID)
|
||||
}
|
||||
if run.Status != "paused" || run.State.Stage != agentInputStep {
|
||||
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
|
||||
}
|
||||
var p inputPause
|
||||
if err := run.State.Scan(&p); err != nil {
|
||||
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
|
||||
}
|
||||
message := p.OriginalMessage
|
||||
if message == "" {
|
||||
message = string(run.State.Data)
|
||||
}
|
||||
message += "\n\nHuman input: " + input
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
run.State.Data = []byte(message)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, message, run.ParentID, &run)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Checkpoint == nil || a.currentRun == nil {
|
||||
return next(ctx, call)
|
||||
}
|
||||
name := toolCheckpointName(call)
|
||||
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
|
||||
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
|
||||
}
|
||||
|
||||
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
res := next(ctx, call)
|
||||
a.currentRun.Steps[idx].Attempts++
|
||||
if res.Refused != "" {
|
||||
a.currentRun.Steps[idx].Status = "failed"
|
||||
a.currentRun.Steps[idx].Error = res.Content
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
return res
|
||||
}
|
||||
a.currentRun.Steps[idx].Status = "done"
|
||||
a.currentRun.Steps[idx].Result = res.Content
|
||||
a.currentRun.Steps[idx].Error = ""
|
||||
_ = a.saveRun(ctx, *a.currentRun)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func toolCheckpointName(call ai.ToolCall) string {
|
||||
b, _ := json.Marshal(call.Input)
|
||||
return "tool:" + call.Name + ":" + string(b)
|
||||
}
|
||||
|
||||
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
|
||||
for _, step := range steps {
|
||||
if step.Name == name {
|
||||
return step, true
|
||||
}
|
||||
}
|
||||
return flow.StepRecord{}, false
|
||||
}
|
||||
|
||||
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
|
||||
for i := range *steps {
|
||||
if (*steps)[i].Name == rec.Name {
|
||||
(*steps)[i].Status = rec.Status
|
||||
(*steps)[i].Error = rec.Error
|
||||
return i
|
||||
}
|
||||
}
|
||||
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
|
||||
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
|
||||
}
|
||||
*steps = append(*steps, rec)
|
||||
return len(*steps) - 1
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"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 TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "tool-resume-agent")
|
||||
toolRuns := 0
|
||||
first := true
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler != nil {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.charge", Input: map[string]any{"order": "42"}})
|
||||
if res.Content != "charged" {
|
||||
t.Fatalf("tool result = %q, want charged", res.Content)
|
||||
}
|
||||
}
|
||||
if first {
|
||||
first = false
|
||||
return nil, errors.New("model connection dropped after tool")
|
||||
}
|
||||
return &ai.Response{Reply: "finished from checkpoint"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("tool-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "charged", nil
|
||||
}))
|
||||
_, err := a.Ask(ctx, "charge order 42")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want simulated failure")
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
|
||||
}
|
||||
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending returned %d runs, want 1", len(runs))
|
||||
}
|
||||
resp, err := Resume(ctx, a, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resp.Reply != "finished from checkpoint" {
|
||||
t.Fatalf("Resume reply = %q", resp.Reply)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
|
||||
}
|
||||
}
|
||||
|
||||
func 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 TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "input-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
if opts.ToolHandler != nil {
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Which region should I deploy to?"}})
|
||||
}
|
||||
return &ai.Response{Reply: "waiting"}, nil
|
||||
}
|
||||
if !strings.Contains(req.Prompt, "Human input: us-east-1") {
|
||||
t.Fatalf("resumed prompt = %q, want human input", req.Prompt)
|
||||
}
|
||||
return &ai.Response{Reply: "deploying to us-east-1"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("input-agent"), WithCheckpoint(cp))
|
||||
_, err := a.Ask(ctx, "deploy the service")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want input-required pause")
|
||||
}
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].Status != "paused" || runs[0].State.Stage != agentInputStep {
|
||||
t.Fatalf("paused runs = %#v, want one input-required run", runs)
|
||||
}
|
||||
var pause inputPause
|
||||
if err := runs[0].State.Scan(&pause); err != nil {
|
||||
t.Fatalf("Scan pause: %v", err)
|
||||
}
|
||||
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Which region should I deploy to?" {
|
||||
t.Fatalf("pause = %#v", pause)
|
||||
}
|
||||
|
||||
if _, err := Resume(ctx, a, runs[0].ID); err == nil || !strings.Contains(err.Error(), "ResumeInput") {
|
||||
t.Fatalf("Resume input-required err = %v, want guidance", err)
|
||||
}
|
||||
resp, err := ResumeInput(ctx, a, runs[0].ID, "us-east-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResumeInput: %v", err)
|
||||
}
|
||||
if resp.RunID != runs[0].ID || resp.Reply != "deploying to us-east-1" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" {
|
||||
t.Fatalf("resumed run status = %q, want done", loaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func 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)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// fakeGen drives the fake provider's Generate. Tests set it and reset
|
||||
// it with a deferred cleanup. Tests in this package are not parallel,
|
||||
// so a package-level hook is safe.
|
||||
var fakeGen func(opts ai.Options, req *ai.Request) (*ai.Response, error)
|
||||
var fakeGen func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error)
|
||||
|
||||
type fakeModel struct{ opts ai.Options }
|
||||
|
||||
@@ -28,7 +28,7 @@ func (m *fakeModel) Init(opts ...ai.Option) error {
|
||||
func (m *fakeModel) Options() ai.Options { return m.opts }
|
||||
func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if fakeGen != nil {
|
||||
return fakeGen(m.opts, req)
|
||||
return fakeGen(ctx, m.opts, req)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func newTestAgent(opts ...Option) *agentImpl {
|
||||
// plan tool persists the plan to memory.
|
||||
func TestAskExposesAndRunsPlan(t *testing.T) {
|
||||
var sawPlan, sawDelegate bool
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
for _, tl := range req.Tools {
|
||||
switch tl.Name {
|
||||
case toolPlan:
|
||||
@@ -112,7 +112,7 @@ func TestAskExposesAndRunsPlan(t *testing.T) {
|
||||
// Delegating with no matching agent creates an ephemeral sub-agent with
|
||||
// a fresh, isolated context (no builtin tools) and returns its reply.
|
||||
func TestDelegateEphemeral(t *testing.T) {
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if strings.Contains(req.SystemPrompt, "sub-agent") {
|
||||
for _, tl := range req.Tools {
|
||||
if tl.Name == toolPlan || tl.Name == toolDelegate {
|
||||
@@ -126,7 +126,7 @@ func TestDelegateEphemeral(t *testing.T) {
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("root"))
|
||||
content := a.handleDelegate(ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "summarize the report"}}).Content
|
||||
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "summarize the report"}}).Content
|
||||
if !strings.Contains(content, "subtask complete") {
|
||||
t.Errorf("delegate should return the sub-agent's reply; got %q", content)
|
||||
}
|
||||
@@ -154,14 +154,14 @@ func TestDelegateToRegisteredAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
// fakeGen guards against the ephemeral path being taken by mistake.
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
t.Error("delegate to a registered agent must not spawn a sub-agent")
|
||||
return &ai.Response{}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("root"), WithRegistry(reg), WithClient(fc))
|
||||
content := a.handleDelegate(ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "notify alice", "to": "comms"}}).Content
|
||||
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "notify alice", "to": "comms"}}).Content
|
||||
|
||||
if calledService != "comms" || calledEndpoint != "Agent.Chat" {
|
||||
t.Errorf("expected RPC to comms Agent.Chat, got %s %s", calledService, calledEndpoint)
|
||||
@@ -174,8 +174,51 @@ func TestDelegateToRegisteredAgent(t *testing.T) {
|
||||
// Delegate requires a task.
|
||||
func TestDelegateRequiresTask(t *testing.T) {
|
||||
a := newTestAgent(Name("root"))
|
||||
content := a.handleDelegate(ai.ToolCall{Name: "delegate", Input: map[string]any{}}).Content
|
||||
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{}}).Content
|
||||
if !strings.Contains(content, "error") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+186
-10
@@ -2,6 +2,9 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -21,6 +24,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 +49,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 +84,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 +109,49 @@ 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)
|
||||
type match struct {
|
||||
msg ai.Message
|
||||
score int
|
||||
index int
|
||||
}
|
||||
matches := make([]match, 0, len(m.archive))
|
||||
for i := len(m.archive) - 1; i >= 0; i-- {
|
||||
msg := m.archive[i]
|
||||
if score := recallScore(msg, terms); score > 0 {
|
||||
matches = append(matches, match{msg: msg, score: score, index: i})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score > matches[j].score
|
||||
}
|
||||
return matches[i].index > matches[j].index
|
||||
})
|
||||
if len(matches) > limit {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
out := make([]ai.Message, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
out = append(out, match.msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *storeMemory) load() {
|
||||
if m.store == nil || m.key == "" {
|
||||
return
|
||||
@@ -73,12 +160,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 +181,94 @@ 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})
|
||||
_ = 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 recallScore(msg ai.Message, terms []string) int {
|
||||
text := strings.ToLower(fmt.Sprint(msg.Content))
|
||||
score := 0
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, term) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func recallTerms(query string) []string {
|
||||
seen := map[string]bool{}
|
||||
var terms []string
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -62,6 +62,46 @@ func TestWithMemoryUsed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
|
||||
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
|
||||
writer := m.(Memory)
|
||||
writer.Add("user", "alpha budget is 42")
|
||||
writer.Add("assistant", "noted")
|
||||
writer.Add("user", "beta budget is 7")
|
||||
writer.Add("assistant", "noted")
|
||||
writer.Add("user", "alpha owner is sam")
|
||||
|
||||
recalled := m.Recall("alpha budget", 2)
|
||||
if len(recalled) == 0 {
|
||||
t.Fatal("expected recalled messages")
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("top recall = %q, want alpha budget match", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewCompactingMemory(st, "agent/reload/history", 3, 1)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
m.Add("assistant", "noted")
|
||||
|
||||
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
|
||||
recall, ok := reloaded.(MemoryRecall)
|
||||
if !ok {
|
||||
t.Fatal("compacting memory should support recall")
|
||||
}
|
||||
recalled := recall.Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
t.Fatalf("recalled %d messages, want 1", len(recalled))
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded recall = %q, want alpha budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom tool is offered to the model and dispatched to its handler.
|
||||
func TestWithToolExposedAndDispatched(t *testing.T) {
|
||||
var got map[string]any
|
||||
|
||||
+92
-5
@@ -2,11 +2,14 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Option configures an Agent.
|
||||
@@ -37,14 +40,36 @@ type Options struct {
|
||||
Provider string
|
||||
Model string
|
||||
APIKey string
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Store store.Store
|
||||
HistoryLimit int
|
||||
|
||||
// ModelTimeout bounds each provider Generate call (0 disables).
|
||||
ModelTimeout time.Duration
|
||||
// ModelMaxAttempts bounds provider Generate attempts including the first
|
||||
// call. Default 1 — retries are opt-in (enable with ModelRetry). A Generate
|
||||
// runs the whole tool-execution turn, so auto-retrying it would re-run
|
||||
// already-executed, possibly side-effecting tool calls; keep it explicit.
|
||||
ModelMaxAttempts int
|
||||
// ModelRetryBackoff is the base delay between transient provider failures
|
||||
// (grows exponentially per attempt when retries are enabled).
|
||||
ModelRetryBackoff time.Duration
|
||||
|
||||
// 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
|
||||
@@ -62,6 +87,10 @@ type Options struct {
|
||||
// on that address directly (no separate gateway), e.g. ":4000".
|
||||
A2AAddress string
|
||||
|
||||
// TraceProvider enables OpenTelemetry spans for agent runs, model calls,
|
||||
// and tool calls. Nil disables instrumentation.
|
||||
TraceProvider trace.TracerProvider
|
||||
|
||||
// tools are developer-registered custom tools (see WithTool).
|
||||
tools []customTool
|
||||
// wrappers are developer-registered tool-execution wrappers
|
||||
@@ -71,10 +100,13 @@ type Options struct {
|
||||
|
||||
func newOptions(opts ...Option) Options {
|
||||
o := Options{
|
||||
Registry: registry.DefaultRegistry,
|
||||
Client: client.DefaultClient,
|
||||
Store: store.DefaultStore,
|
||||
HistoryLimit: 50,
|
||||
Registry: registry.DefaultRegistry,
|
||||
Client: client.DefaultClient,
|
||||
Store: store.DefaultStore,
|
||||
HistoryLimit: 50,
|
||||
ModelTimeout: 30 * time.Second,
|
||||
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
|
||||
ModelRetryBackoff: 100 * time.Millisecond,
|
||||
// On by default and lenient: identical repeated calls are a
|
||||
// no-progress loop, never useful. Set LoopLimit(0) to disable.
|
||||
LoopLimit: 3,
|
||||
@@ -115,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 }
|
||||
@@ -155,6 +194,19 @@ func LoopLimit(n int) Option {
|
||||
return func(o *Options) { o.LoopLimit = n }
|
||||
}
|
||||
|
||||
// ModelCallTimeout sets the timeout for each provider Generate call.
|
||||
func ModelCallTimeout(d time.Duration) Option {
|
||||
return func(o *Options) { o.ModelTimeout = d }
|
||||
}
|
||||
|
||||
// ModelRetry sets the provider retry budget and backoff for transient failures.
|
||||
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.ModelMaxAttempts = maxAttempts
|
||||
o.ModelRetryBackoff = backoff
|
||||
}
|
||||
}
|
||||
|
||||
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
|
||||
// ":4000"), so other agents can reach it directly by URL without a
|
||||
// separate gateway. The agent stays a normal go-micro service as well;
|
||||
@@ -170,7 +222,35 @@ func WithMemory(m Memory) Option {
|
||||
return func(o *Options) { o.Memory = m }
|
||||
}
|
||||
|
||||
// WrapTool registers a tool-execution wrapper, the tool-side analogue of
|
||||
// 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
|
||||
// tool executes, code after runs after. Use it for logging, metrics,
|
||||
@@ -208,3 +288,10 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const agentInstrumentationName = "go-micro.dev/v6/agent"
|
||||
|
||||
const (
|
||||
spanNameRun = "agent.run"
|
||||
spanNameModelCall = "agent.model.call"
|
||||
spanNameToolCall = "agent.tool.call"
|
||||
|
||||
AttrRunID = "agent.run.id"
|
||||
AttrParentRunID = "agent.run.parent_id"
|
||||
AttrAgentName = "agent.name"
|
||||
AttrProvider = "agent.model.provider"
|
||||
AttrModel = "agent.model.name"
|
||||
AttrLatencyMS = "agent.latency_ms"
|
||||
AttrInputTokens = "agent.tokens.input"
|
||||
AttrOutputTokens = "agent.tokens.output"
|
||||
AttrTotalTokens = "agent.tokens.total"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
AttrRefusal = "agent.refusal"
|
||||
)
|
||||
|
||||
type RunEvent struct {
|
||||
Time time.Time `json:"time"`
|
||||
RunID string `json:"run_id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Tokens Usage `json:"tokens,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
return a.opts.TraceProvider.Tracer(agentInstrumentationName)
|
||||
}
|
||||
|
||||
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, 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)))
|
||||
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
type tracedModel struct {
|
||||
ai.Model
|
||||
a *agentImpl
|
||||
}
|
||||
|
||||
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) {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
provider := m.String()
|
||||
model := m.Options().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)}
|
||||
usage := ai.Usage{}
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
attrs = appendUsage(attrs, usage)
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
m.a.recordSpanEvent(span, e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
|
||||
if u.InputTokens > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrInputTokens, u.InputTokens))
|
||||
}
|
||||
if u.OutputTokens > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrOutputTokens, u.OutputTokens))
|
||||
}
|
||||
if u.TotalTokens > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrTotalTokens, u.TotalTokens))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
start := time.Now()
|
||||
|
||||
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)}
|
||||
if res.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
resErr := resultError(res)
|
||||
if res.Refused != "" {
|
||||
span.SetStatus(codes.Error, res.Refused)
|
||||
} else if resErr != "" {
|
||||
span.SetStatus(codes.Error, resErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr})
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func resultError(res ai.ToolResult) string {
|
||||
if m, ok := res.Value.(map[string]string); ok {
|
||||
return m["error"]
|
||||
}
|
||||
if m, ok := res.Value.(map[string]any); ok {
|
||||
if err, _ := m["error"].(string); err != "" {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
span.AddEvent("agent."+e.Kind, trace.WithTimestamp(e.Time), trace.WithAttributes(runEventAttributes(e)...))
|
||||
a.recordRunEvent(e)
|
||||
}
|
||||
|
||||
func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrRunID, e.RunID),
|
||||
attribute.String(AttrAgentName, e.Agent),
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
|
||||
}
|
||||
if e.Name != "" {
|
||||
attrs = append(attrs, attribute.String("agent.event.name", e.Name))
|
||||
}
|
||||
if e.Provider != "" {
|
||||
attrs = append(attrs, attribute.String(AttrProvider, e.Provider))
|
||||
}
|
||||
if e.Model != "" {
|
||||
attrs = append(attrs, attribute.String(AttrModel, e.Model))
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
|
||||
}
|
||||
attrs = appendUsage(attrs, e.Tokens)
|
||||
if e.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
|
||||
}
|
||||
if e.Error != "" {
|
||||
attrs = append(attrs, attribute.String("agent.error", e.Error))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if e.RunID == "" {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(e)
|
||||
key := fmt.Sprintf("runs/%s/%020d-%s", e.RunID, e.Time.UnixNano(), e.Kind)
|
||||
_ = a.stateStore().Write(&store.Record{Key: key, Value: b})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
runs := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
parts := strings.Split(k, "/")
|
||||
if len(parts) >= 2 && parts[1] != "" {
|
||||
runs[parts[1]] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(runs))
|
||||
for id := range runs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
summaries := make([]RunSummary, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
events, err := LoadRunEvents(s, agentName, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
continue
|
||||
}
|
||||
first := events[0]
|
||||
last := events[len(events)-1]
|
||||
summary := RunSummary{
|
||||
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 != "" {
|
||||
summary.Agent = e.Agent
|
||||
}
|
||||
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 + "/"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
events := make([]RunEvent, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
recs, err := st.Read(k)
|
||||
if err != nil || len(recs) == 0 {
|
||||
continue
|
||||
}
|
||||
var e RunEvent
|
||||
if json.Unmarshal(recs[0].Value, &e) == nil {
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type otelTestModel struct{ opts ai.Options }
|
||||
|
||||
func (m *otelTestModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *otelTestModel) Options() ai.Options { return m.opts }
|
||||
func (m *otelTestModel) String() string { return "oteltest" }
|
||||
func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *otelTestModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func init() {
|
||||
ai.Register("oteltest", func(opts ...ai.Option) ai.Model { return &otelTestModel{opts: ai.NewOptions(opts...)} })
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("runner"), Provider("oteltest"), Model("unit-model"), WithStore(st), TraceProvider(tp), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
var runEvents []trace.Event
|
||||
for _, s := range spans {
|
||||
if s.Name() == spanNameRun {
|
||||
runEvents = s.Events()
|
||||
break
|
||||
}
|
||||
}
|
||||
if !spanEventHasRunInfo(runEvents, "agent.run", runID, "runner") || !spanEventHasRunInfo(runEvents, "agent.done", runID, "runner") {
|
||||
t.Fatalf("run span missing run-info events: %#v", runEvents)
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
|
||||
for _, event := range events {
|
||||
if event.Name != name {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(event.Attributes)
|
||||
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner-noop").List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected run timeline without TraceProvider")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRunEventsSortsTimelineKeys(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
runID := "run-1"
|
||||
events := []RunEvent{
|
||||
{Time: time.Unix(0, 3), RunID: runID, Agent: "runner", Kind: "tool", Name: "third"},
|
||||
{Time: time.Unix(0, 1), RunID: runID, Agent: "runner", Kind: "run", Name: "first"},
|
||||
{Time: time.Unix(0, 2), RunID: runID, Agent: "runner", Kind: "model", Name: "second"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "runs/" + runID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
|
||||
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := LoadRunEvents(st, "runner", runID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(got))
|
||||
}
|
||||
for i, want := range []string{"first", "second", "third"} {
|
||||
if got[i].Name != want {
|
||||
t.Fatalf("event %d = %q, want %q (timeline: %#v)", i, got[i].Name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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", 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"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
|
||||
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ListRunSummaries(st, "runner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d summaries, want 2: %#v", len(got), got)
|
||||
}
|
||||
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
|
||||
t.Fatalf("unexpected run-a summary: %#v", got[0])
|
||||
}
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "error" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "boom" {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestAskCancellationAbortsPromptly(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("cancel"), ModelCallTimeout(time.Second), ModelRetry(3, time.Millisecond))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := a.Ask(ctx, "stop")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Ask error = %v, want context canceled", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("Ask took %s after cancellation, want prompt abort", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskRetriesTransientErrorsThenSucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("retry-success"), ModelRetry(3, time.Millisecond))
|
||||
resp, err := a.Ask(context.Background(), "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask returned error: %v", err)
|
||||
}
|
||||
if resp.Reply != "ok" {
|
||||
t.Fatalf("reply = %q, want ok", resp.Reply)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
|
||||
attempts := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("retry-fail"), ModelRetry(2, time.Millisecond))
|
||||
_, err := a.Ask(context.Background(), "hello")
|
||||
var retryErr *ai.RetryError
|
||||
if !errors.As(err, &retryErr) {
|
||||
t.Fatalf("Ask error = %T %v, want *ai.RetryError", err, err)
|
||||
}
|
||||
if retryErr.Attempts != 2 {
|
||||
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
|
||||
}
|
||||
if attempts != 2 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// A registered wrapper runs around every tool call and can observe and
|
||||
@@ -82,6 +85,88 @@ func TestWrapToolSeesGuardrailRefusal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A guardrail refusal carries a structured reason a wrapper can switch on,
|
||||
// so reliability tooling (e.g. loop handling) needn't parse the message.
|
||||
func TestWrapToolSeesRefusedReason(t *testing.T) {
|
||||
a := newTestAgent(Name("looper"), LoopLimit(2))
|
||||
h := a.toolHandler()
|
||||
|
||||
var last ai.ToolResult
|
||||
for i := 0; i < 3; i++ {
|
||||
last = h(context.Background(), ai.ToolCall{ID: "x", Name: "demo_Svc_Do", Input: map[string]any{"q": "same"}})
|
||||
}
|
||||
if last.Refused != ai.RefusedLoop {
|
||||
t.Errorf("Refused = %q, want %q", last.Refused, ai.RefusedLoop)
|
||||
}
|
||||
}
|
||||
|
||||
// ctxMock is a model that forwards the Generate context to the tool
|
||||
// handler (as real providers do), so a wrapper can read ai.RunInfo.
|
||||
type ctxMock struct{ opts ai.Options }
|
||||
|
||||
func (m *ctxMock) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *ctxMock) Options() ai.Options { return m.opts }
|
||||
func (m *ctxMock) String() string { return "ctxmock" }
|
||||
func (m *ctxMock) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("no stream")
|
||||
}
|
||||
func (m *ctxMock) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "c1", Name: "demo_Svc_Do", Input: map[string]any{}})
|
||||
}
|
||||
return &ai.Response{Answer: "done"}, nil
|
||||
}
|
||||
|
||||
// During an Ask, a wrapper sees RunInfo on the context: a correlation id
|
||||
// for the run and the agent's name.
|
||||
func TestWrapToolSeesRunInfo(t *testing.T) {
|
||||
ai.Register("ctxmock", func(opts ...ai.Option) ai.Model {
|
||||
m := &ctxMock{}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
})
|
||||
|
||||
var got ai.RunInfo
|
||||
var ok bool
|
||||
a := New(
|
||||
Name("runner"),
|
||||
Provider("ctxmock"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
got, ok = ai.RunInfoFrom(ctx)
|
||||
return next(ctx, call)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(context.Background(), "go")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("wrapper did not see RunInfo on the context")
|
||||
}
|
||||
if got.Agent != "runner" {
|
||||
t.Errorf("RunInfo.Agent = %q, want runner", got.Agent)
|
||||
}
|
||||
if got.RunID == "" {
|
||||
t.Error("RunInfo.RunID is empty")
|
||||
}
|
||||
if resp.RunID != got.RunID {
|
||||
t.Errorf("Response.RunID = %q, want wrapper RunID %q", resp.RunID, got.RunID)
|
||||
}
|
||||
if resp.ParentID != "" {
|
||||
t.Errorf("Response.ParentID = %q, want empty", resp.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
// call.Scan decodes a tool call's input into a typed struct.
|
||||
func TestToolCallScan(t *testing.T) {
|
||||
call := ai.ToolCall{Input: map[string]any{"query": "hello", "limit": 5}}
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-13
@@ -77,11 +77,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
"messages": threadAnthropicMessages(req),
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
@@ -101,10 +99,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
// Tool execution loop: execute tools, send results back, repeat
|
||||
// until the model responds with text only (no more tool calls)
|
||||
messages := []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": cleanContent(rawContent)},
|
||||
}
|
||||
messages := append(threadAnthropicMessages(req),
|
||||
map[string]any{"role": "assistant", "content": cleanContent(rawContent)},
|
||||
)
|
||||
|
||||
pendingCalls := resp.ToolCalls
|
||||
|
||||
@@ -127,7 +124,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": messages,
|
||||
}
|
||||
@@ -161,7 +158,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
|
||||
@@ -174,7 +171,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -193,7 +190,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
@@ -264,9 +261,30 @@ func cleanContent(raw any) any {
|
||||
cleaned = append(cleaned, map[string]any{"type": "text", "text": b.Text})
|
||||
case "tool_use":
|
||||
var input any
|
||||
json.Unmarshal(b.Input, &input)
|
||||
_ = json.Unmarshal(b.Input, &input)
|
||||
cleaned = append(cleaned, map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": input})
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// threadAnthropicMessages builds the Anthropic messages array from the
|
||||
// conversation history (req.Messages) followed by the current prompt. The
|
||||
// system prompt is sent separately via the top-level "system" field.
|
||||
func threadAnthropicMessages(req *ai.Request) []map[string]any {
|
||||
msgs := make([]map[string]any, 0, len(req.Messages)+1)
|
||||
for _, m := range req.Messages {
|
||||
msgs = append(msgs, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
msgs = append(msgs, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func anthropicMaxTokens(o ai.Options) int {
|
||||
if o.MaxTokens > 0 {
|
||||
return o.MaxTokens
|
||||
}
|
||||
return 8192
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+122
-10
@@ -20,6 +20,7 @@
|
||||
package atlascloud
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -91,13 +92,21 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
@@ -142,8 +151,111 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
|
||||
// chat completions endpoint, emitting content deltas as they arrive.
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
return &atlasStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type atlasStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *atlasStream) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
// Final chunk (after include_usage) carries token usage and no content.
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *atlasStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
@@ -153,7 +265,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -168,7 +280,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
@@ -259,7 +371,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateImage"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -273,7 +385,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
@@ -315,7 +427,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
|
||||
}
|
||||
|
||||
func (p *Provider) pollPrediction(ctx context.Context, url string) (*ai.ImageResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -395,7 +507,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateVideo"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -409,7 +521,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
@@ -450,7 +562,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
|
||||
}
|
||||
|
||||
func (p *Provider) pollVideo(ctx context.Context, url string) (*ai.VideoResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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) {
|
||||
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
|
||||
"/v1beta/models/" + p.opts.Model + ":generateContent"
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-3
@@ -4,6 +4,7 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,13 @@ type Message struct {
|
||||
Content any // Can be string or structured content
|
||||
}
|
||||
|
||||
// Usage describes token counts returned by model providers.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// Response represents the response from a model
|
||||
type Response struct {
|
||||
// Reply is the text response from the model
|
||||
@@ -56,6 +64,8 @@ type Response struct {
|
||||
ToolCalls []ToolCall
|
||||
// Answer is the final answer after tool execution (if tools were used)
|
||||
Answer string
|
||||
// Usage contains provider token usage when available.
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
// ToolCall represents a request to call a tool and its result
|
||||
@@ -86,9 +96,59 @@ type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
// Refused names the reason a guardrail blocked the call before it ran
|
||||
// ("max_steps", "loop", "approval"); empty when the call executed. A
|
||||
// tool wrapper can switch on it to build reliability tooling — react to
|
||||
// a detected loop, audit refusals — without parsing the message.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
// Refusal reason codes set on ToolResult.Refused by the agent's guardrails.
|
||||
const (
|
||||
RefusedMaxSteps = "max_steps"
|
||||
RefusedLoop = "loop"
|
||||
RefusedApproval = "approval"
|
||||
)
|
||||
|
||||
// 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. 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 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{}
|
||||
|
||||
// WithRunInfo attaches run info to ctx.
|
||||
func WithRunInfo(ctx context.Context, r RunInfo) context.Context {
|
||||
return context.WithValue(ctx, runInfoKey{}, r)
|
||||
}
|
||||
|
||||
// RunInfoFrom returns the run info attached to ctx, and whether it was set.
|
||||
func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
|
||||
r, ok := ctx.Value(runInfoKey{}).(RunInfo)
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -101,8 +161,8 @@ type Stream interface {
|
||||
// the same mental model carries over from services to tools.
|
||||
type ToolHandler func(ctx context.Context, call ToolCall) ToolResult
|
||||
|
||||
// ToolWrapper wraps a ToolHandler to add behaviour around execution —
|
||||
// logging, metrics, retries, guardrails. It is the tool-side analogue of
|
||||
// ToolWrapper wraps a ToolHandler to add behavior around execution —
|
||||
// logging, metrics, retries, guardrails. It is the tool-side analog of
|
||||
// client.CallWrapper and server.HandlerWrapper: a wrapper takes the next
|
||||
// handler and returns a new one, and code before the next(...) call runs
|
||||
// before the tool, code after runs after.
|
||||
|
||||
+124
-7
@@ -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
|
||||
@@ -83,7 +85,12 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
// Build messages
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
@@ -91,6 +98,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
if len(openaiTools) > 0 {
|
||||
apiReq["tools"] = openaiTools
|
||||
@@ -140,9 +150,110 @@ 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},
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
return &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"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
// Final chunk (after include_usage) carries token usage and no content.
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *openAIStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
@@ -155,7 +266,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -173,12 +284,17 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var chatResp struct {
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
@@ -204,6 +320,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{
|
||||
Reply: choice.Message.Content,
|
||||
Usage: ai.Usage{InputTokens: chatResp.Usage.PromptTokens, OutputTokens: chatResp.Usage.CompletionTokens, TotalTokens: chatResp.Usage.TotalTokens},
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
@@ -255,7 +372,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/images/generations"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -270,7 +387,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
@@ -80,16 +86,96 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {bad json}\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("Recv returned nil error for malformed chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamCloseReleasesResponse(t *testing.T) {
|
||||
released := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
close(released)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-released:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not observe closed stream request")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ type Options struct {
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
// MaxTokens caps the length of the response (0 = provider default)
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
@@ -91,3 +93,11 @@ func WithTools(t *Tools) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxTokens caps the number of tokens in the response. 0 leaves the
|
||||
// provider default in place.
|
||||
func WithMaxTokens(n int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxTokens = n
|
||||
}
|
||||
}
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatusCoder is implemented by provider errors that expose an HTTP-like status code.
|
||||
type StatusCoder interface {
|
||||
StatusCode() int
|
||||
}
|
||||
|
||||
// RetryAfterCoder is implemented by provider errors that expose a server
|
||||
// supplied retry delay, such as HTTP Retry-After on a 429/503 response.
|
||||
type RetryAfterCoder interface {
|
||||
RetryAfter() time.Duration
|
||||
}
|
||||
|
||||
// ErrorKind classifies provider-boundary failures into stable buckets callers
|
||||
// can inspect without parsing provider-specific error strings.
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorKindUnknown ErrorKind = "unknown"
|
||||
ErrorKindCanceled ErrorKind = "canceled"
|
||||
ErrorKindTimeout ErrorKind = "timeout"
|
||||
ErrorKindRateLimited ErrorKind = "rate_limited"
|
||||
ErrorKindUnavailable ErrorKind = "unavailable"
|
||||
ErrorKindProvider ErrorKind = "provider"
|
||||
)
|
||||
|
||||
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
|
||||
type ClassifiedError interface {
|
||||
ErrorKind() ErrorKind
|
||||
}
|
||||
|
||||
// RetryError is returned when Generate is retried and still fails.
|
||||
type RetryError struct {
|
||||
Attempts int
|
||||
Kind ErrorKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RetryError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("ai generate failed after %d attempt(s) (%s): %v", e.Attempts, e.ErrorKind(), e.Err)
|
||||
}
|
||||
|
||||
func (e *RetryError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *RetryError) ErrorKind() ErrorKind {
|
||||
if e == nil || e.Kind == "" {
|
||||
return ErrorKindUnknown
|
||||
}
|
||||
return e.Kind
|
||||
}
|
||||
|
||||
// GeneratePolicy controls timeout and retry behavior for a model call.
|
||||
type GeneratePolicy struct {
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Backoff time.Duration
|
||||
}
|
||||
|
||||
// GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry.
|
||||
func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy GeneratePolicy, opts ...GenerateOption) (*Response, error) {
|
||||
if policy.MaxAttempts <= 0 {
|
||||
policy.MaxAttempts = 1
|
||||
}
|
||||
if m == nil {
|
||||
return nil, errors.New("ai model is nil")
|
||||
}
|
||||
|
||||
var last error
|
||||
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
cancel := func() {}
|
||||
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 {
|
||||
return resp, nil
|
||||
}
|
||||
last = err
|
||||
|
||||
// Caller cancellation/deadline always wins and is not retried.
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
transient := IsTransientError(err)
|
||||
if attempt == policy.MaxAttempts || !transient {
|
||||
if attempt > 1 || transient {
|
||||
return nil, &RetryError{Attempts: attempt, Kind: ClassifyError(err), Err: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Always back off between retries — exponential and capped — so an
|
||||
// opt-in retry can never become a tight loop hammering the provider,
|
||||
// even if Backoff was left at zero.
|
||||
backoff := retryBackoff(err, attempt, policy.Backoff)
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !t.Stop() {
|
||||
<-t.C
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
|
||||
}
|
||||
|
||||
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
|
||||
backoff := base
|
||||
if backoff <= 0 {
|
||||
backoff = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
backoff <<= shift
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
|
||||
var retryAfter RetryAfterCoder
|
||||
if errors.As(err, &retryAfter) {
|
||||
if delay := retryAfter.RetryAfter(); delay > backoff {
|
||||
backoff = delay
|
||||
}
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return backoff
|
||||
}
|
||||
|
||||
// ClassifyError maps provider and context failures to stable operational kinds.
|
||||
func ClassifyError(err error) ErrorKind {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var classified ClassifiedError
|
||||
if errors.As(err, &classified) {
|
||||
if kind := classified.ErrorKind(); kind != "" {
|
||||
return kind
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return ErrorKindCanceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return ErrorKindTimeout
|
||||
}
|
||||
var sc StatusCoder
|
||||
if errors.As(err, &sc) {
|
||||
code := sc.StatusCode()
|
||||
switch {
|
||||
case code == 429:
|
||||
return ErrorKindRateLimited
|
||||
case code >= 500:
|
||||
return ErrorKindUnavailable
|
||||
case code > 0:
|
||||
return ErrorKindProvider
|
||||
}
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests"):
|
||||
return ErrorKindRateLimited
|
||||
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
|
||||
return ErrorKindTimeout
|
||||
case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"):
|
||||
return ErrorKindUnavailable
|
||||
default:
|
||||
return ErrorKindUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// IsTransientError reports whether err is worth retrying at the provider boundary.
|
||||
func IsTransientError(err error) bool {
|
||||
switch ClassifyError(err) {
|
||||
case ErrorKindTimeout, ErrorKindRateLimited, ErrorKindUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type statusErr int
|
||||
|
||||
func (e statusErr) Error() string { return "provider status" }
|
||||
func (e statusErr) StatusCode() int { return int(e) }
|
||||
|
||||
type retryAfterErr struct {
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (e retryAfterErr) Error() string { return "rate limit exceeded" }
|
||||
func (e retryAfterErr) StatusCode() int { return 429 }
|
||||
func (e retryAfterErr) RetryAfter() time.Duration { return e.delay }
|
||||
|
||||
func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want ErrorKind
|
||||
}{
|
||||
{name: "canceled", err: context.Canceled, want: ErrorKindCanceled},
|
||||
{name: "timeout", err: context.DeadlineExceeded, want: ErrorKindTimeout},
|
||||
{name: "rate limit status", err: statusErr(429), want: ErrorKindRateLimited},
|
||||
{name: "unavailable status", err: statusErr(503), want: ErrorKindUnavailable},
|
||||
{name: "provider status", err: statusErr(400), want: ErrorKindProvider},
|
||||
{name: "rate limit text", err: errors.New("rate limit exceeded"), want: ErrorKindRateLimited},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ClassifyError(tt.err); got != tt.want {
|
||||
t.Fatalf("ClassifyError() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryExposesRetryErrorKind(t *testing.T) {
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
return nil, statusErr(429)
|
||||
}}
|
||||
|
||||
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
var retryErr *RetryError
|
||||
if !errors.As(err, &retryErr) {
|
||||
t.Fatalf("error = %T %[1]v, want RetryError", err)
|
||||
}
|
||||
if retryErr.ErrorKind() != ErrorKindRateLimited {
|
||||
t.Fatalf("retry kind = %q, want %q", retryErr.ErrorKind(), ErrorKindRateLimited)
|
||||
}
|
||||
if !errors.Is(err, statusErr(429)) {
|
||||
t.Fatalf("retry error does not unwrap provider status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsRetryAfterWhenLongerThanBackoff(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, retryAfterErr{delay: 25 * time.Millisecond}
|
||||
}
|
||||
return &Response{Reply: "ok"}, nil
|
||||
}}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithRetry returned error: %v", err)
|
||||
}
|
||||
if resp.Reply != "ok" {
|
||||
t.Fatalf("reply = %q, want ok", resp.Reply)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 20*time.Millisecond {
|
||||
t.Fatalf("retry delay = %s, want RetryAfter delay to dominate base backoff", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
|
||||
if got := retryBackoff(retryAfterErr{delay: time.Minute}, 1, time.Millisecond); got != 30*time.Second {
|
||||
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -17,12 +17,12 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v6/codec/json"
|
||||
merr "go-micro.dev/v6/errors"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/registry/cache"
|
||||
"go-micro.dev/v6/transport/headers"
|
||||
maddr "go-micro.dev/v6/internal/util/addr"
|
||||
mnet "go-micro.dev/v6/internal/util/net"
|
||||
mls "go-micro.dev/v6/internal/util/tls"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/registry/cache"
|
||||
"go-micro.dev/v6/transport/headers"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
@@ -100,7 +100,7 @@ func newTransport(config *tls.Config) *http.Transport {
|
||||
})
|
||||
|
||||
// setup http2
|
||||
http2.ConfigureTransport(t)
|
||||
_ = http2.ConfigureTransport(t)
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -288,19 +288,19 @@ func (h *httpBroker) run(l net.Listener) {
|
||||
}
|
||||
|
||||
func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != "POST" {
|
||||
if req.Method != http.MethodPost {
|
||||
err := merr.BadRequest("go.micro.broker", "Method not allowed")
|
||||
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close()
|
||||
|
||||
req.ParseForm()
|
||||
_ = req.ParseForm()
|
||||
|
||||
b, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
errr := merr.InternalServerError("go.micro.broker", "Error reading request body: %v", err)
|
||||
w.WriteHeader(500)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(errr.Error()))
|
||||
return
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
var m *Message
|
||||
if err = h.opts.Codec.Unmarshal(b, &m); err != nil {
|
||||
errr := merr.InternalServerError("go.micro.broker", "Error parsing request body: %v", err)
|
||||
w.WriteHeader(500)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(errr.Error()))
|
||||
return
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
if len(topic) == 0 {
|
||||
errr := merr.InternalServerError("go.micro.broker", "Topic not found")
|
||||
w.WriteHeader(500)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(errr.Error()))
|
||||
return
|
||||
}
|
||||
@@ -406,7 +406,7 @@ func (h *httpBroker) Connect() error {
|
||||
addr := h.address
|
||||
h.address = l.Addr().String()
|
||||
|
||||
go http.Serve(l, h.mux)
|
||||
go func() { _ = http.Serve(l, h.mux) }()
|
||||
go func() {
|
||||
h.run(l)
|
||||
h.Lock()
|
||||
@@ -555,7 +555,7 @@ func (h *httpBroker) Publish(topic string, msg *Message, opts ...PublishOption)
|
||||
}
|
||||
|
||||
// discard response body
|
||||
io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
r.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
+3
-1
@@ -161,7 +161,9 @@ func pub(b *testing.B, c int) {
|
||||
go func() {
|
||||
for range ch {
|
||||
if err := brk.Publish(topic, msg); err != nil {
|
||||
b.Fatalf("Unexpected publish error: %v", err)
|
||||
b.Errorf("Unexpected publish error: %v", err)
|
||||
wg.Done()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "go-micro.dev/v6/logger"
|
||||
maddr "go-micro.dev/v6/internal/util/addr"
|
||||
mnet "go-micro.dev/v6/internal/util/net"
|
||||
log "go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
type memoryBroker struct {
|
||||
@@ -122,7 +122,7 @@ func (m *memoryBroker) Publish(topic string, msg *Message, opts ...PublishOption
|
||||
if err := sub.handler(p); err != nil {
|
||||
p.err = err
|
||||
if eh := m.opts.ErrorHandler; eh != nil {
|
||||
eh(p)
|
||||
_ = eh(p)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
|
||||
+8
-8
@@ -194,7 +194,7 @@ func (n *natsBroker) Disconnect() error {
|
||||
if n.conn != nil {
|
||||
// drain the connection if specified
|
||||
if n.drain {
|
||||
n.conn.Drain()
|
||||
_ = n.conn.Drain()
|
||||
n.closeCh <- nil
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ func (n *natsBroker) Publish(topic string, msg *broker.Message, opts ...broker.P
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer n.pool.Put(poolConn)
|
||||
defer func() { _ = n.pool.Put(poolConn) }()
|
||||
|
||||
conn := poolConn.Conn()
|
||||
if conn == nil {
|
||||
@@ -279,7 +279,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
|
||||
m.Body = msg.Data
|
||||
n.opts.Logger.Log(logger.ErrorLevel, err)
|
||||
if eh != nil {
|
||||
eh(pub)
|
||||
_ = eh(pub)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
|
||||
pub.err = err
|
||||
n.opts.Logger.Log(logger.ErrorLevel, err)
|
||||
if eh != nil {
|
||||
eh(pub)
|
||||
_ = eh(pub)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
|
||||
|
||||
conn := poolConn.Conn()
|
||||
if conn == nil {
|
||||
n.pool.Put(poolConn)
|
||||
_ = n.pool.Put(poolConn)
|
||||
return nil, errors.New("invalid connection from pool")
|
||||
}
|
||||
|
||||
@@ -315,13 +315,13 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
n.pool.Put(poolConn)
|
||||
_ = n.pool.Put(poolConn)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return connection to pool after subscription is created
|
||||
// The subscription keeps the connection alive
|
||||
n.pool.Put(poolConn)
|
||||
_ = n.pool.Put(poolConn)
|
||||
|
||||
return &subscriber{s: sub, opts: opt}, nil
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func (n *natsBroker) setOption(opts ...broker.Option) {
|
||||
o(&n.opts)
|
||||
}
|
||||
|
||||
n.Once.Do(func() {
|
||||
n.Do(func() {
|
||||
n.nopts = natsp.GetDefaultOptions()
|
||||
n.poolSize = 1 // Default to single connection (no pooling)
|
||||
n.poolIdleTimeout = 5 * time.Minute
|
||||
|
||||
@@ -61,14 +61,14 @@ func (r *rabbitMQChannel) Connect(prefetchCount int, prefetchGlobal bool, confir
|
||||
|
||||
func (r *rabbitMQChannel) Close() error {
|
||||
if r.channel == nil {
|
||||
return errors.New("Channel is nil")
|
||||
return errors.New("channel is nil")
|
||||
}
|
||||
return r.channel.Close()
|
||||
}
|
||||
|
||||
func (r *rabbitMQChannel) Publish(exchange, key string, message amqp.Publishing) error {
|
||||
if r.channel == nil {
|
||||
return errors.New("Channel is nil")
|
||||
return errors.New("channel is nil")
|
||||
}
|
||||
|
||||
if r.confirmPublish != nil {
|
||||
@@ -84,11 +84,11 @@ func (r *rabbitMQChannel) Publish(exchange, key string, message amqp.Publishing)
|
||||
if r.confirmPublish != nil {
|
||||
confirmation, ok := <-r.confirmPublish
|
||||
if !ok {
|
||||
return errors.New("Channel closed before could receive confirmation of publish")
|
||||
return errors.New("channel closed before could receive confirmation of publish")
|
||||
}
|
||||
|
||||
if !confirmation.Ack {
|
||||
return errors.New("Could not publish message, received nack from broker on confirmation")
|
||||
return errors.New("could not publish message, received nack from broker on confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ type MQExchangeType string
|
||||
|
||||
const (
|
||||
ExchangeTypeFanout MQExchangeType = "fanout"
|
||||
ExchangeTypeTopic = "topic"
|
||||
ExchangeTypeDirect = "direct"
|
||||
ExchangeTypeTopic MQExchangeType = "topic"
|
||||
ExchangeTypeDirect MQExchangeType = "direct"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -46,8 +46,6 @@ var (
|
||||
Locale: defaultLocale,
|
||||
}
|
||||
|
||||
dial = amqp.Dial
|
||||
dialTLS = amqp.DialTLS
|
||||
dialConfig = amqp.DialConfig
|
||||
)
|
||||
|
||||
@@ -251,9 +249,9 @@ func (r *rabbitMQConn) tryConnect(secure bool, config *amqp.Config) error {
|
||||
|
||||
if !r.withoutExchange {
|
||||
if r.exchange.Durable {
|
||||
r.Channel.DeclareDurableExchange(r.exchange)
|
||||
_ = r.Channel.DeclareDurableExchange(r.exchange)
|
||||
} else {
|
||||
r.Channel.DeclareExchange(r.exchange)
|
||||
_ = r.Channel.DeclareExchange(r.exchange)
|
||||
}
|
||||
r.ExchangeChannel, err = newRabbitChannel(r.Connection, r.prefetchCount, r.prefetchGlobal, r.confirmPublish)
|
||||
}
|
||||
|
||||
@@ -15,13 +15,11 @@ import (
|
||||
)
|
||||
|
||||
type rbroker struct {
|
||||
conn *rabbitMQConn
|
||||
addrs []string
|
||||
opts broker.Options
|
||||
prefetchCount int
|
||||
prefetchGlobal bool
|
||||
mtx sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
conn *rabbitMQConn
|
||||
addrs []string
|
||||
opts broker.Options
|
||||
mtx sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
@@ -304,9 +302,9 @@ func (r *rbroker) Subscribe(topic string, handler broker.Handler, opts ...broker
|
||||
p := &publication{d: msg, m: m, t: msg.RoutingKey}
|
||||
p.err = handler(p)
|
||||
if p.err == nil && ackSuccess && !opt.AutoAck {
|
||||
msg.Ack(false)
|
||||
_ = msg.Ack(false)
|
||||
} else if p.err != nil && !opt.AutoAck {
|
||||
msg.Nack(false, requeueOnError)
|
||||
_ = msg.Nack(false, requeueOnError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestWithoutExchange(t *testing.T) {
|
||||
rabbitmq.DeliveryMode(2),
|
||||
rabbitmq.ContentType("application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestFanoutExchange(t *testing.T) {
|
||||
rabbitmq.DeliveryMode(2),
|
||||
rabbitmq.ContentType("application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -231,7 +231,7 @@ func TestDirectExchange(t *testing.T) {
|
||||
rabbitmq.DeliveryMode(2),
|
||||
rabbitmq.ContentType("application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestTopicExchange(t *testing.T) {
|
||||
rabbitmq.DeliveryMode(2),
|
||||
rabbitmq.ContentType("application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
Vendored
+7
-7
@@ -14,8 +14,8 @@ type memCache struct {
|
||||
}
|
||||
|
||||
func (c *memCache) Get(ctx context.Context, key string) (interface{}, time.Time, error) {
|
||||
c.RWMutex.RLock()
|
||||
defer c.RWMutex.RUnlock()
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
item, found := c.items[key]
|
||||
if !found {
|
||||
@@ -37,8 +37,8 @@ func (c *memCache) Put(ctx context.Context, key string, val interface{}, d time.
|
||||
e = time.Now().Add(d).UnixNano()
|
||||
}
|
||||
|
||||
c.RWMutex.Lock()
|
||||
defer c.RWMutex.Unlock()
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
c.items[key] = Item{
|
||||
Value: val,
|
||||
@@ -49,8 +49,8 @@ func (c *memCache) Put(ctx context.Context, key string, val interface{}, d time.
|
||||
}
|
||||
|
||||
func (c *memCache) Delete(ctx context.Context, key string) error {
|
||||
c.RWMutex.Lock()
|
||||
defer c.RWMutex.Unlock()
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
_, found := c.items[key]
|
||||
if !found {
|
||||
@@ -61,6 +61,6 @@ func (c *memCache) Delete(ctx context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memCache) String() string {
|
||||
func (c *memCache) String() string {
|
||||
return "memory"
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -52,6 +52,6 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
return c.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (m *redisCache) String() string {
|
||||
func (c *redisCache) String() string {
|
||||
return "redis"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func UseNumber() {
|
||||
}
|
||||
|
||||
func (w wrapCodec) String() string {
|
||||
return w.Codec.Name()
|
||||
return w.Name()
|
||||
}
|
||||
|
||||
func (w wrapCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
|
||||
+1
-1
@@ -16,10 +16,10 @@ import (
|
||||
"go-micro.dev/v6/cmd"
|
||||
raw "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/errors"
|
||||
pnet "go-micro.dev/v6/internal/util/net"
|
||||
"go-micro.dev/v6/metadata"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
pnet "go-micro.dev/v6/internal/util/net"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/encoding"
|
||||
|
||||
@@ -202,7 +202,6 @@ func removeConn(conn *poolConn) {
|
||||
conn.next = nil
|
||||
conn.in = false
|
||||
conn.sp.count--
|
||||
return
|
||||
}
|
||||
|
||||
func addConnAfter(conn *poolConn, after *poolConn) {
|
||||
@@ -214,5 +213,4 @@ func addConnAfter(conn *poolConn, after *poolConn) {
|
||||
after.next = conn
|
||||
conn.in = true
|
||||
conn.sp.count++
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ func Registry(r registry.Registry) Option {
|
||||
return func(o *Options) {
|
||||
o.Registry = r
|
||||
// set in the selector
|
||||
o.Selector.Init(selector.Registry(r))
|
||||
_ = o.Selector.Init(selector.Registry(r))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ import (
|
||||
"go-micro.dev/v6/codec"
|
||||
raw "go-micro.dev/v6/codec/bytes"
|
||||
merrors "go-micro.dev/v6/errors"
|
||||
"go-micro.dev/v6/internal/util/buf"
|
||||
"go-micro.dev/v6/internal/util/net"
|
||||
"go-micro.dev/v6/internal/util/pool"
|
||||
log "go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/metadata"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/transport"
|
||||
"go-micro.dev/v6/transport/headers"
|
||||
"go-micro.dev/v6/internal/util/buf"
|
||||
"go-micro.dev/v6/internal/util/net"
|
||||
"go-micro.dev/v6/internal/util/pool"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+1
-1
@@ -23,13 +23,13 @@ import (
|
||||
"go-micro.dev/v6/debug/trace"
|
||||
"go-micro.dev/v6/events"
|
||||
"go-micro.dev/v6/logger"
|
||||
mprofile "go-micro.dev/v6/service/profile"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/registry/consul"
|
||||
"go-micro.dev/v6/registry/etcd"
|
||||
"go-micro.dev/v6/registry/nats"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/server"
|
||||
mprofile "go-micro.dev/v6/service/profile"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/store/mysql"
|
||||
natsjskv "go-micro.dev/v6/store/nats-js-kv"
|
||||
|
||||
@@ -28,7 +28,7 @@ Examples:
|
||||
Agents are discovered from the registry (the ones advertising type=agent);
|
||||
an Agent Card is generated for each from its registry metadata, and
|
||||
incoming A2A tasks are translated to the agent's Agent.Chat RPC. This is
|
||||
the agent-side analogue of 'micro mcp', which exposes services as tools.`,
|
||||
the agent-side analog of 'micro mcp', which exposes services as tools.`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "serve",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+47
-4
@@ -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
|
||||
@@ -172,7 +176,7 @@ func (s *session) callAgent(ctx context.Context, name, message string) (*agent.R
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
var input map[string]any
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
_ = json.Unmarshal([]byte(tc.Input), &input)
|
||||
r.ToolCalls = append(r.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
@@ -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.
|
||||
@@ -586,7 +629,7 @@ func (s *session) printAgentResponse(resp *agent.Response) {
|
||||
func (s *session) cleanup() {
|
||||
for _, p := range s.procs {
|
||||
if p.Process != nil {
|
||||
p.Process.Kill()
|
||||
_ = p.Process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
@@ -13,6 +15,23 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
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, c.Bool("json"))
|
||||
}
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
})
|
||||
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents",
|
||||
@@ -79,12 +98,19 @@ func init() {
|
||||
},
|
||||
{
|
||||
Name: "history",
|
||||
Usage: "Show an agent's stored conversation history",
|
||||
ArgsUsage: "[name]",
|
||||
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]")
|
||||
return fmt.Errorf("usage: micro agent history [name] [run-id]")
|
||||
}
|
||||
if runID := c.Args().Get(1); 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
|
||||
@@ -93,14 +119,128 @@ func init() {
|
||||
msgs := mem.Messages()
|
||||
if len(msgs) == 0 {
|
||||
fmt.Printf(" No history for agent %q.\n", name)
|
||||
return nil
|
||||
} else {
|
||||
for _, m := range msgs {
|
||||
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
|
||||
}
|
||||
}
|
||||
for _, m := range msgs {
|
||||
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
|
||||
}
|
||||
return nil
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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.Fprintf(w, " No runs recorded for agent %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(w, " Runs:")
|
||||
for _, run := range runs {
|
||||
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.Fprintln(w, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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.Fprintf(w, " No run %q for agent %q.\n", runID, name)
|
||||
return nil
|
||||
}
|
||||
for _, e := range events {
|
||||
line := fmt.Sprintf(" %s %-5s", e.Time.Format("15:04:05.000"), e.Kind)
|
||||
if e.Name != "" {
|
||||
line += " " + e.Name
|
||||
}
|
||||
if e.Provider != "" || e.Model != "" {
|
||||
line += fmt.Sprintf(" %s/%s", e.Provider, e.Model)
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
line += fmt.Sprintf(" %dms", e.LatencyMS)
|
||||
}
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,6 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/remote"
|
||||
)
|
||||
|
||||
var (
|
||||
// version is set by the release action
|
||||
// this is the default for local builds
|
||||
version = "5.0.0-dev"
|
||||
)
|
||||
|
||||
func genProtoHandler(c *cli.Context) error {
|
||||
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -52,6 +46,10 @@ func init() {
|
||||
Name: "no-mcp",
|
||||
Usage: "Disable MCP gateway integration in generated code",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "proto",
|
||||
Usage: "Use Protocol Buffers (requires protoc); default is reflection-based, no protoc needed",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "template",
|
||||
Usage: "Service template: default, crud, pubsub, api",
|
||||
@@ -117,7 +115,7 @@ func init() {
|
||||
args := ctx.Args()
|
||||
|
||||
if args.Len() < 2 {
|
||||
return fmt.Errorf("Usage: [service] [endpoint] [request]")
|
||||
return fmt.Errorf("usage: [service] [endpoint] [request]")
|
||||
}
|
||||
|
||||
service := args.Get(0)
|
||||
@@ -153,7 +151,7 @@ func init() {
|
||||
args := ctx.Args()
|
||||
|
||||
if args.Len() != 1 {
|
||||
return fmt.Errorf("Usage: [service]")
|
||||
return fmt.Errorf("usage: [service]")
|
||||
}
|
||||
|
||||
service := args.Get(0)
|
||||
|
||||
@@ -42,18 +42,32 @@ func Deploy(c *cli.Context) error {
|
||||
return showDeployHelp()
|
||||
}
|
||||
|
||||
target, remotePath := resolveDeployTarget(c, target, cfg)
|
||||
|
||||
return deploySSH(c, target, cfg, remotePath)
|
||||
}
|
||||
|
||||
func resolveDeployTarget(c *cli.Context, target string, cfg *config.Config) (string, string) {
|
||||
remotePath := c.String("path")
|
||||
if remotePath == "" {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
|
||||
// Check if target is a named target from config
|
||||
if cfg != nil {
|
||||
if dt, ok := cfg.Deploy[target]; ok {
|
||||
target = dt.SSH
|
||||
if dt.Path != "" && !c.IsSet("path") {
|
||||
remotePath = dt.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deploySSH(c, target, cfg)
|
||||
return target, remotePath
|
||||
}
|
||||
|
||||
func showDeployHelp() error {
|
||||
return fmt.Errorf(`No deployment target specified.
|
||||
return fmt.Errorf(`no deployment target specified.
|
||||
|
||||
To deploy, you need a server running micro. Quick setup:
|
||||
|
||||
@@ -69,7 +83,7 @@ To deploy, you need a server running micro. Quick setup:
|
||||
deploy prod
|
||||
ssh user@your-server
|
||||
|
||||
Run 'micro deploy --help' for more options.`)
|
||||
Run 'micro deploy --help' for more options`)
|
||||
}
|
||||
|
||||
func showDeployTargets(cfg *config.Config) error {
|
||||
@@ -82,7 +96,7 @@ func showDeployTargets(cfg *config.Config) error {
|
||||
return fmt.Errorf("%s", sb.String())
|
||||
}
|
||||
|
||||
func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
|
||||
dir := c.Args().Get(1)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
@@ -98,7 +112,6 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
cfg, _ = config.Load(absDir)
|
||||
}
|
||||
|
||||
remotePath := c.String("path")
|
||||
if remotePath == "" {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
@@ -271,14 +284,6 @@ func checkServerInit(host, remotePath string) error {
|
||||
func buildBinaries(absDir string, cfg *config.Config, forceBuild bool, servicesToBuild []string) error {
|
||||
binDir := filepath.Join(absDir, "bin")
|
||||
|
||||
// Check if we already have binaries and don't need to rebuild
|
||||
if !forceBuild {
|
||||
if _, err := os.Stat(binDir); err == nil {
|
||||
// Check if binaries are for linux
|
||||
// For now, just rebuild to be safe
|
||||
}
|
||||
}
|
||||
|
||||
// Always build for linux/amd64
|
||||
targetOS := "linux"
|
||||
targetArch := "amd64"
|
||||
@@ -391,7 +396,7 @@ func setupSystemdServices(target, remotePath string, services []string) error {
|
||||
// Enable the service using the template
|
||||
enableCmd := fmt.Sprintf("sudo systemctl enable micro@%s 2>/dev/null || true", svc)
|
||||
sshCmd := exec.Command("ssh", target, enableCmd)
|
||||
sshCmd.Run() // Ignore errors, service might already be enabled
|
||||
_ = sshCmd.Run() // Ignore errors, service might already be enabled
|
||||
}
|
||||
|
||||
// Reload systemd
|
||||
@@ -430,7 +435,7 @@ func checkServicesHealth(target string, services []string) (healthy, unhealthy [
|
||||
// Ensure we're not on Windows for deploy
|
||||
func checkPlatform() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Errorf("micro deploy requires SSH and rsync, which work best on Linux/macOS.\nConsider using WSL on Windows.")
|
||||
return fmt.Errorf("micro deploy requires SSH and rsync, which work best on Linux/macOS.\nConsider using WSL on Windows")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/cmd/micro/run/config"
|
||||
)
|
||||
|
||||
func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
set := flag.NewFlagSet("deploy", flag.ContinueOnError)
|
||||
set.String("path", defaultRemotePath, "")
|
||||
set.String("ssh", "", "")
|
||||
set.String("service", "", "")
|
||||
set.Bool("build", false, "")
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse flags: %v", err)
|
||||
}
|
||||
return cli.NewContext(cli.NewApp(), set, nil)
|
||||
}
|
||||
|
||||
func TestDeployNoTargetExplainsInitAndDeployHandoff(t *testing.T) {
|
||||
err := showDeployHelp()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing target guidance")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"no deployment target specified",
|
||||
"sudo micro init --server",
|
||||
"micro deploy user@your-server",
|
||||
"deploy prod",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in guidance:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployListsConfiguredTargetsWhenNoTargetProvided(t *testing.T) {
|
||||
err := showDeployTargets(&config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com"},
|
||||
"staging": {Name: "staging", SSH: "deploy@staging.example.com"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected configured target guidance")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"Available deploy targets:",
|
||||
"prod -> deploy@prod.example.com",
|
||||
"staging -> deploy@staging.example.com",
|
||||
"micro deploy <target>",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in configured target guidance:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeployTargetUsesConfigTargetAndPath(t *testing.T) {
|
||||
ctx := newDeployTestContext(t, "prod")
|
||||
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
|
||||
}}
|
||||
|
||||
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
|
||||
if target != "deploy@prod.example.com" {
|
||||
t.Fatalf("target = %q, want configured SSH", target)
|
||||
}
|
||||
if remotePath != "/srv/micro" {
|
||||
t.Fatalf("remotePath = %q, want configured path", remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeployTargetAllowsCLIPathOverride(t *testing.T) {
|
||||
ctx := newDeployTestContext(t, "--path", "/tmp/micro", "prod")
|
||||
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
|
||||
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
|
||||
}}
|
||||
|
||||
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
|
||||
if target != "deploy@prod.example.com" {
|
||||
t.Fatalf("target = %q, want configured SSH", target)
|
||||
}
|
||||
if remotePath != "/tmp/micro" {
|
||||
t.Fatalf("remotePath = %q, want CLI override", remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployConfigParserSupportsDeployTargets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/micro.mu"
|
||||
content := `service api
|
||||
path ./api
|
||||
|
||||
deploy prod
|
||||
ssh deploy@prod.example.com
|
||||
path /srv/micro
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.ParseMu(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
prod := cfg.Deploy["prod"]
|
||||
if prod == nil {
|
||||
t.Fatal("missing prod deploy target")
|
||||
}
|
||||
if prod.SSH != "deploy@prod.example.com" || prod.Path != "/srv/micro" {
|
||||
t.Fatalf("deploy target = %#v", prod)
|
||||
}
|
||||
}
|
||||
@@ -288,8 +288,8 @@ func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provid
|
||||
}
|
||||
|
||||
// Step 2: Run go mod tidy + make proto to get compiled proto
|
||||
runIn(svcDir, "go", "mod", "tidy")
|
||||
runIn(svcDir, "make", "proto")
|
||||
_ = runIn(svcDir, "go", "mod", "tidy")
|
||||
_ = runIn(svcDir, "make", "proto")
|
||||
|
||||
// Step 3: Generate handler with business logic (LLM)
|
||||
proto := readFile(protoFile)
|
||||
@@ -328,8 +328,8 @@ func generateStructure(dir string, svc ServiceSpec) error {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
exists = true
|
||||
}
|
||||
os.MkdirAll(filepath.Join(dir, "handler"), 0755)
|
||||
os.MkdirAll(filepath.Join(dir, "proto"), 0755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "handler"), 0755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "proto"), 0755)
|
||||
|
||||
name := svc.Name
|
||||
titleName := toTitle(name)
|
||||
@@ -521,7 +521,7 @@ func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
|
||||
for _, ep := range svc.Endpoints {
|
||||
switch ep.Name {
|
||||
case "Create":
|
||||
b.WriteString(fmt.Sprintf("message CreateRequest {\n"))
|
||||
b.WriteString("message CreateRequest {\n")
|
||||
n := 1
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
|
||||
@@ -545,7 +545,7 @@ func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
|
||||
case "Delete":
|
||||
b.WriteString(fmt.Sprintf("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n"))
|
||||
b.WriteString("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")
|
||||
case "List":
|
||||
b.WriteString(fmt.Sprintf("message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName))
|
||||
default:
|
||||
@@ -596,7 +596,7 @@ func generateAgent(baseDir string, design *ServiceDesign, svcNames []string) err
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
os.MkdirAll(agentDir, 0755)
|
||||
_ = os.MkdirAll(agentDir, 0755)
|
||||
|
||||
// Build a description of all services for the agent prompt
|
||||
var svcDescs []string
|
||||
@@ -634,7 +634,7 @@ func main() {
|
||||
writeFile(filepath.Join(agentDir, "go.mod"),
|
||||
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", agentName, goMicroVersion))
|
||||
|
||||
runIn(agentDir, "go", "mod", "tidy")
|
||||
_ = runIn(agentDir, "go", "mod", "tidy")
|
||||
|
||||
fmt.Printf(" \033[35m◆\033[0m agent \033[2m(manages: %s)\033[0m\n", strings.Join(svcNames, ", "))
|
||||
return nil
|
||||
@@ -752,7 +752,7 @@ func readFile(path string) string {
|
||||
}
|
||||
|
||||
func writeFile(path, content string) {
|
||||
os.WriteFile(path, []byte(content), 0644)
|
||||
_ = os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func runIn(dir string, name string, args ...string) error {
|
||||
@@ -829,13 +829,13 @@ func readMeta(svcDir string) map[string]string {
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
json.Unmarshal(b, &m)
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func writeMeta(svcDir string, m map[string]string) {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
os.WriteFile(metaPath(svcDir), b, 0644)
|
||||
_ = os.WriteFile(metaPath(svcDir), b, 0644)
|
||||
}
|
||||
|
||||
func fileModified(svcDir, key, path string) bool {
|
||||
|
||||
@@ -312,7 +312,6 @@ func TestGenerateStructure(t *testing.T) {
|
||||
|
||||
// Proto should be protected if user modified it
|
||||
protoFile := filepath.Join(svcDir, "proto", "test-svc.proto")
|
||||
protoBefore, _ := os.ReadFile(protoFile)
|
||||
os.WriteFile(protoFile, []byte("// user-edited proto\n"), 0644)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -328,9 +327,7 @@ func TestGenerateStructure(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protoAfter2, _ := os.ReadFile(protoFile)
|
||||
if string(protoAfter2) == string(protoBefore) {
|
||||
// ok — regenerated from spec
|
||||
}
|
||||
_ = protoAfter2
|
||||
}
|
||||
|
||||
func TestFileModified(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// TestZeroToOneContract locks the documented getting-started path:
|
||||
// `micro new helloworld` must produce an ordinary Go service that the Go
|
||||
// toolchain can build, run long enough to start, and call through its generated
|
||||
// handler. The generated module is pointed back at this checkout so the
|
||||
// contract stays local and deterministic in CI.
|
||||
//
|
||||
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
|
||||
// it needs the Go toolchain and module access; it is skipped under `-short`.
|
||||
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)
|
||||
generated.run(t)
|
||||
generated.call(t, "Alice", "Hello Alice")
|
||||
}
|
||||
|
||||
// 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)
|
||||
generated.run(t)
|
||||
generated.call(t, "Bob", "Hello Bob")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(oldwd)
|
||||
|
||||
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
|
||||
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 %s %s: %v", strings.Join(args, " "), name, err)
|
||||
}
|
||||
|
||||
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
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(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 = g.dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) run(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
bin := filepath.Join(g.dir, "service-contract")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Dir = g.dir
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generated service go build -o service-contract . failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Dir = g.dir
|
||||
var out strings.Builder
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("generated service failed to start: %v\n%s", err, out.String())
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("generated service exited early: %v\n%s", err, out.String())
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("failed to stop generated service: %v\n%s", err, out.String())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("generated service did not stop after kill\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) call(t *testing.T, name, want string) {
|
||||
t.Helper()
|
||||
|
||||
testPath := filepath.Join(g.dir, "handler", "contract_test.go")
|
||||
testSrc := `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedCallContract(t *testing.T) {
|
||||
rsp := new(Response)
|
||||
if err := New().Call(context.Background(), &Request{Name: "` + name + `"}, rsp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rsp.Msg != "` + want + `" {
|
||||
t.Fatalf("Call response = %q, want %q", rsp.Msg, "` + want + `")
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(testPath, []byte(testSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "test", "./handler", "-run", "TestGeneratedCallContract", "-count=1")
|
||||
cmd.Dir = g.dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service call contract failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
+107
-36
@@ -12,6 +12,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
@@ -24,17 +25,6 @@ import (
|
||||
tmpl "go-micro.dev/v6/cmd/micro/cli/new/template"
|
||||
)
|
||||
|
||||
func protoComments(goDir, alias string) []string {
|
||||
return []string{
|
||||
"\ndownload protoc zip packages (protoc-$VERSION-$PLATFORM.zip) and install:\n",
|
||||
"visit https://github.com/protocolbuffers/protobuf/releases",
|
||||
"\ncompile the proto file " + alias + ".proto:\n",
|
||||
"cd " + alias,
|
||||
"go mod tidy",
|
||||
"make proto\n",
|
||||
}
|
||||
}
|
||||
|
||||
type config struct {
|
||||
// foo
|
||||
Alias string
|
||||
@@ -46,12 +36,38 @@ type config struct {
|
||||
GoPath string
|
||||
// UseGoPath
|
||||
UseGoPath bool
|
||||
// MicroVersion is the go-micro version to require in go.mod
|
||||
MicroVersion string
|
||||
// Files
|
||||
Files []file
|
||||
// Comments
|
||||
Comments []string
|
||||
}
|
||||
|
||||
// microVersion returns the go-micro version this CLI was built from, so a
|
||||
// generated service requires the same framework version the user is running.
|
||||
// Falls back to "latest" for local/dev builds (resolved by 'go mod tidy').
|
||||
func microVersion() string {
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "latest"
|
||||
}
|
||||
isRelease := func(v string) bool {
|
||||
return strings.HasPrefix(v, "v") && !strings.Contains(v, "devel")
|
||||
}
|
||||
// cmd/micro is part of the go-micro.dev/v6 module, so for an installed
|
||||
// binary the main module version is the framework version.
|
||||
if bi.Main.Path == "go-micro.dev/v6" && isRelease(bi.Main.Version) {
|
||||
return bi.Main.Version
|
||||
}
|
||||
for _, dep := range bi.Deps {
|
||||
if dep.Path == "go-micro.dev/v6" && isRelease(dep.Version) {
|
||||
return dep.Version
|
||||
}
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Path string
|
||||
Tmpl string
|
||||
@@ -162,13 +178,6 @@ func Run(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for protoc
|
||||
if _, err := exec.LookPath("protoc"); err != nil {
|
||||
fmt.Println("WARNING: protoc is not installed or not in your PATH.")
|
||||
fmt.Println("Please install protoc from https://github.com/protocolbuffers/protobuf/releases")
|
||||
fmt.Println("After installing, re-run 'make proto' in your service directory if needed.")
|
||||
}
|
||||
|
||||
var goPath string
|
||||
var goDir string
|
||||
|
||||
@@ -191,29 +200,53 @@ func Run(ctx *cli.Context) error {
|
||||
noMCP := ctx.Bool("no-mcp")
|
||||
templateName := ctx.String("template")
|
||||
|
||||
// Select templates based on --template flag
|
||||
mainTmpl, handlerTmpl, protoTmpl := selectTemplates(templateName, noMCP)
|
||||
// The default template is protoless: handlers are registered by
|
||||
// reflection, so the service builds and runs with no protoc toolchain.
|
||||
// --proto opts into Protocol Buffers; the named templates (crud, pubsub,
|
||||
// api) are proto-based and imply it.
|
||||
useProto := ctx.Bool("proto") || (templateName != "" && templateName != "default")
|
||||
|
||||
c := config{
|
||||
Alias: dir,
|
||||
Comments: nil,
|
||||
Dir: dir,
|
||||
GoDir: goDir,
|
||||
GoPath: goPath,
|
||||
UseGoPath: false,
|
||||
Files: []file{
|
||||
Alias: dir,
|
||||
Comments: nil,
|
||||
Dir: dir,
|
||||
GoDir: goDir,
|
||||
GoPath: goPath,
|
||||
UseGoPath: false,
|
||||
MicroVersion: microVersion(),
|
||||
}
|
||||
|
||||
if useProto {
|
||||
mainTmpl, handlerTmpl, protoTmpl := selectTemplates(templateName, noMCP)
|
||||
c.Files = []file{
|
||||
{"main.go", mainTmpl},
|
||||
{"handler/" + dir + ".go", handlerTmpl},
|
||||
{"proto/" + dir + ".proto", protoTmpl},
|
||||
{"Makefile", tmpl.Makefile},
|
||||
{"README.md", tmpl.Readme},
|
||||
{".gitignore", tmpl.GitIgnore},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mainTmpl := tmpl.MainNoProto
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.MainNoProtoNoMCP
|
||||
}
|
||||
c.Files = []file{
|
||||
{"main.go", mainTmpl},
|
||||
{"handler/" + dir + ".go", tmpl.HandlerNoProto},
|
||||
{"Makefile", tmpl.MakefileNoProto},
|
||||
{"README.md", tmpl.ReadmeNoProto},
|
||||
{".gitignore", tmpl.GitIgnore},
|
||||
}
|
||||
}
|
||||
|
||||
// set gomodule
|
||||
if os.Getenv("GO111MODULE") != "off" {
|
||||
c.Files = append(c.Files, file{"go.mod", tmpl.Module})
|
||||
mod := tmpl.ModuleNoProto
|
||||
if useProto {
|
||||
mod = tmpl.Module
|
||||
}
|
||||
c.Files = append(c.Files, file{"go.mod", mod})
|
||||
}
|
||||
|
||||
// create the files
|
||||
@@ -221,17 +254,28 @@ func Run(ctx *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run go mod tidy and make proto
|
||||
fmt.Println("\nRunning 'go mod tidy' and 'make proto'...")
|
||||
// Resolve dependencies.
|
||||
fmt.Println("\nRunning 'go mod tidy'...")
|
||||
if err := runInDir(dir, "go mod tidy"); err != nil {
|
||||
fmt.Printf("Error running 'go mod tidy': %v\n", err)
|
||||
}
|
||||
if err := runInDir(dir, "make proto"); err != nil {
|
||||
fmt.Printf("Error running 'make proto': %v\n", err)
|
||||
|
||||
// Generate protobuf code only when the proto workflow is used, and only
|
||||
// when the toolchain is present. Otherwise print install instructions
|
||||
// rather than failing with a cryptic error.
|
||||
if useProto {
|
||||
if missing := missingProtoTools(); len(missing) > 0 {
|
||||
printProtoInstall(dir, missing)
|
||||
} else {
|
||||
fmt.Println("Running 'make proto'...")
|
||||
if err := runInDir(dir, "make proto"); err != nil {
|
||||
fmt.Printf("Error running 'make proto': %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print updated tree including generated files
|
||||
fmt.Println("\nProject structure after 'make proto':")
|
||||
fmt.Println("\nProject structure:")
|
||||
printTree(dir)
|
||||
|
||||
fmt.Println()
|
||||
@@ -281,6 +325,33 @@ func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl
|
||||
}
|
||||
}
|
||||
|
||||
// missingProtoTools returns the protobuf tools needed by `make proto` that
|
||||
// are not on the PATH.
|
||||
func missingProtoTools() []string {
|
||||
var missing []string
|
||||
for _, tool := range []string{"protoc", "protoc-gen-go", "protoc-gen-micro"} {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
missing = append(missing, tool)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// printProtoInstall tells the user exactly what to install to generate the
|
||||
// protobuf code, instead of failing with a cryptic plugin error.
|
||||
func printProtoInstall(dir string, missing []string) {
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[33m!\033[0m This service uses Protocol Buffers, but these tools are missing: %s\n", strings.Join(missing, ", "))
|
||||
fmt.Println()
|
||||
fmt.Println(" Install them:")
|
||||
fmt.Println(" protoc https://github.com/protocolbuffers/protobuf/releases (or via your package manager)")
|
||||
fmt.Println(" protoc-gen-go go install google.golang.org/protobuf/cmd/protoc-gen-go@latest")
|
||||
fmt.Println(" protoc-gen-micro go install go-micro.dev/v6/cmd/protoc-gen-micro@latest")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Then generate the code:\n cd %s && make proto && go run .\n", dir)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func runInDir(dir, cmd string) error {
|
||||
parts := strings.Fields(cmd)
|
||||
c := exec.Command(parts[0], parts[1:]...)
|
||||
@@ -315,7 +386,7 @@ func printTree(dir string) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
filepath.Walk(dir, walk)
|
||||
_ = filepath.Walk(dir, walk)
|
||||
fmt.Println(t.String())
|
||||
}
|
||||
|
||||
@@ -359,7 +430,7 @@ func runPrompt(cliCtx *cli.Context, prompt string) error {
|
||||
fmt.Println()
|
||||
|
||||
if !confirmGenerate() {
|
||||
fmt.Println(" Cancelled.")
|
||||
fmt.Println(" Canceled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,43 @@ func (e *{{title .Alias}}) Stream(ctx context.Context, req *pb.StreamingRequest,
|
||||
|
||||
return nil
|
||||
}
|
||||
`
|
||||
|
||||
// HandlerNoProto is the default handler: plain Go request/response types
|
||||
// registered by reflection. No generated protobuf code, so no protoc.
|
||||
HandlerNoProto = `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
// Request is the input to {{title .Alias}}.Call.
|
||||
type Request struct {
|
||||
Name string ` + "`json:\"name\" description:\"Name to greet (required)\"`" + `
|
||||
}
|
||||
|
||||
// Response is the output of {{title .Alias}}.Call.
|
||||
type Response struct {
|
||||
Msg string ` + "`json:\"msg\"`" + `
|
||||
}
|
||||
|
||||
type {{title .Alias}} struct{}
|
||||
|
||||
// Return a new handler.
|
||||
func New() *{{title .Alias}} {
|
||||
return &{{title .Alias}}{}
|
||||
}
|
||||
|
||||
// Call greets a person by name and returns a welcome message.
|
||||
//
|
||||
// @example {"name": "Alice"}
|
||||
func (e *{{title .Alias}}) Call(ctx context.Context, req *Request, rsp *Response) error {
|
||||
log.Info("Received {{title .Alias}}.Call request")
|
||||
rsp.Msg = "Hello " + req.Name
|
||||
return nil
|
||||
}
|
||||
`
|
||||
|
||||
SubscriberSRV = `package subscriber
|
||||
|
||||
@@ -50,5 +50,62 @@ func main() {
|
||||
// Run service
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
|
||||
// MainNoProto is the default template: handlers are registered by
|
||||
// reflection, so the service builds and runs with no protoc toolchain.
|
||||
MainNoProto = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
"go-micro.dev/v6/gateway/mcp"
|
||||
log "go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.NewService("{{lower .Alias}}",
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
|
||||
// Initialize service
|
||||
service.Init()
|
||||
|
||||
// Register handler (reflection-based — no protoc required)
|
||||
if err := service.Handle(handler.New()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Run service
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
|
||||
MainNoProtoNoMCP = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
log "go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.NewService("{{lower .Alias}}")
|
||||
|
||||
// Initialize service
|
||||
service.Init()
|
||||
|
||||
// Register handler (reflection-based — no protoc required)
|
||||
if err := service.Handle(handler.New()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Run service
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
package template
|
||||
|
||||
// MakefileNoProto is the default Makefile: no proto target, nothing to
|
||||
// generate, so the service builds and runs with no protoc toolchain.
|
||||
var MakefileNoProto = `.PHONY: build run dev test test-coverage clean docker lint fmt deps
|
||||
|
||||
# Build the service
|
||||
build:
|
||||
go build -o bin/{{.Alias}} .
|
||||
|
||||
# Run the service
|
||||
run:
|
||||
go run .
|
||||
|
||||
# Run with hot reload (requires air: go install github.com/air-verse/air@latest)
|
||||
dev:
|
||||
air
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# List MCP tools exposed by this service
|
||||
mcp-tools:
|
||||
micro mcp list
|
||||
|
||||
# Start MCP server for Claude Code
|
||||
mcp-serve:
|
||||
micro mcp serve
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/ coverage.out coverage.html
|
||||
|
||||
# Build Docker image
|
||||
docker:
|
||||
docker build -t {{.Alias}}:latest .
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
go fmt ./...
|
||||
goimports -w .
|
||||
|
||||
# Update dependencies
|
||||
deps:
|
||||
go mod tidy
|
||||
go mod download
|
||||
`
|
||||
|
||||
var Makefile = `.PHONY: proto build run test clean docker
|
||||
|
||||
# Generate protobuf files
|
||||
|
||||
@@ -6,9 +6,19 @@ var (
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go-micro.dev/v6 latest
|
||||
go-micro.dev/v6 {{.MicroVersion}}
|
||||
github.com/golang/protobuf latest
|
||||
google.golang.org/protobuf latest
|
||||
)
|
||||
`
|
||||
|
||||
// ModuleNoProto is the default go.mod: no protobuf dependencies.
|
||||
// MicroVersion is the version this CLI was built from (or "latest"), so a
|
||||
// generated service tracks the framework the user is actually running.
|
||||
ModuleNoProto = `module {{.Dir}}
|
||||
|
||||
go 1.23
|
||||
|
||||
require go-micro.dev/v6 {{.MicroVersion}}
|
||||
`
|
||||
)
|
||||
|
||||
@@ -1,6 +1,79 @@
|
||||
package template
|
||||
|
||||
var (
|
||||
// ReadmeNoProto is the default README: handlers are reflection-based, so
|
||||
// there is no proto generation step and no protoc prerequisite.
|
||||
ReadmeNoProto = `# {{title .Alias}} Service
|
||||
|
||||
Generated with
|
||||
|
||||
` + "```" + `
|
||||
micro new {{.Alias}}
|
||||
` + "```" + `
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run the service — no code generation, no protoc, nothing else to install:
|
||||
|
||||
` + "```bash" + `
|
||||
go run .
|
||||
` + "```" + `
|
||||
|
||||
Handlers are registered by reflection from plain Go types, so request and
|
||||
response structs are defined directly in ` + "`handler/`" + `. To use Protocol
|
||||
Buffers instead, regenerate with ` + "`micro new {{.Alias}} --proto`" + `.
|
||||
|
||||
## MCP & AI Agents
|
||||
|
||||
This service is MCP-enabled by default. When running, AI agents can discover
|
||||
and call your service endpoints automatically.
|
||||
|
||||
**MCP tools endpoint:** http://localhost:3001/mcp/tools
|
||||
|
||||
### Test with curl
|
||||
|
||||
` + "```bash" + `
|
||||
# List available tools
|
||||
curl http://localhost:3001/mcp/tools | jq
|
||||
|
||||
# Call the service via MCP
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "{{lower .Alias}}.{{title .Alias}}.Call", "arguments": {"name": "Alice"}}'
|
||||
` + "```" + `
|
||||
|
||||
### Use with Claude Code
|
||||
|
||||
` + "```bash" + `
|
||||
# Start MCP server for Claude Code
|
||||
micro mcp serve
|
||||
` + "```" + `
|
||||
|
||||
### Writing Good Tool Descriptions
|
||||
|
||||
AI agents work best when your handler methods have clear doc comments:
|
||||
|
||||
` + "```go" + `
|
||||
// CreateUser registers a new user account with the given email and name.
|
||||
// Returns the created user with their assigned ID.
|
||||
//
|
||||
// @example {"email": "alice@example.com", "name": "Alice Smith"}
|
||||
func (s *Users) CreateUser(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
// ...
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
See the [tool descriptions guide](https://go-micro.dev/docs/guides/tool-descriptions) for more tips.
|
||||
|
||||
## Development
|
||||
|
||||
` + "```bash" + `
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make dev # Run with hot reload (requires air)
|
||||
` + "```" + `
|
||||
`
|
||||
|
||||
Readme = `# {{title .Alias}} Service
|
||||
|
||||
Generated with
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
const defaultRemotePath = "/opt/micro"
|
||||
|
||||
// Status shows status of services (local or remote)
|
||||
func Status(c *cli.Context) error {
|
||||
remoteHost := c.String("remote")
|
||||
@@ -57,7 +55,7 @@ func localStatus(c *cli.Context) error {
|
||||
var dir string
|
||||
scanner := bufio.NewScanner(pidFile)
|
||||
if scanner.Scan() {
|
||||
fmt.Sscanf(scanner.Text(), "%d", &pid)
|
||||
_, _ = fmt.Sscanf(scanner.Text(), "%d", &pid)
|
||||
}
|
||||
if scanner.Scan() {
|
||||
dir = scanner.Text()
|
||||
@@ -229,7 +227,7 @@ func remoteLogs(host, service string, follow bool, lines int) error {
|
||||
// Stop stops a running service
|
||||
func Stop(c *cli.Context) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("Usage: micro stop <service>")
|
||||
return fmt.Errorf("usage: micro stop <service>")
|
||||
}
|
||||
|
||||
service := c.Args().First()
|
||||
@@ -258,7 +256,7 @@ func localStop(service string) error {
|
||||
var pid int
|
||||
scanner := bufio.NewScanner(pidFile)
|
||||
if scanner.Scan() {
|
||||
fmt.Sscanf(scanner.Text(), "%d", &pid)
|
||||
_, _ = fmt.Sscanf(scanner.Text(), "%d", &pid)
|
||||
}
|
||||
pidFile.Close()
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func FormatServiceUsage(srv *registry.Service, c *cli.Context) string {
|
||||
if len(subcommand) > 0 && subcommand != "--help" {
|
||||
result += fmt.Sprintf("NAME:\n\tmicro %v %v\n\n", alias, subcommand)
|
||||
result += fmt.Sprintf("USAGE:\n\tmicro %v %v [flags]\n\n", alias, subcommand)
|
||||
result += fmt.Sprintf("FLAGS:\n")
|
||||
result += "FLAGS:\n"
|
||||
|
||||
for i, command := range commands {
|
||||
if command == subcommand {
|
||||
@@ -150,7 +150,7 @@ func CallService(srv *registry.Service, args []string) error {
|
||||
}
|
||||
}
|
||||
if ep == nil {
|
||||
return fmt.Errorf("Endpoint %v not found for service %v", endpoint, srv.Name)
|
||||
return fmt.Errorf("endpoint %v not found for service %v", endpoint, srv.Name)
|
||||
}
|
||||
|
||||
// create a context for the call
|
||||
@@ -189,7 +189,7 @@ func CallService(srv *registry.Service, args []string) error {
|
||||
return err
|
||||
}
|
||||
out.Write([]byte("\n"))
|
||||
out.WriteTo(os.Stdout)
|
||||
_, _ = out.WriteTo(os.Stdout)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func splitCmdArgs(arguments []string) ([]string, map[string][]string, error) {
|
||||
case 2:
|
||||
flags[comps[0]] = append(flags[comps[0]], comps[1])
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("Invalid flag: %v. Expected format: --foo=bar", a)
|
||||
return nil, nil, fmt.Errorf("invalid flag: %v. Expected format: --foo=bar", a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func constructEndpoint(args []string) (string, error) {
|
||||
case 3:
|
||||
epComps = args[1:3]
|
||||
default:
|
||||
return "", fmt.Errorf("Incorrect number of arguments")
|
||||
return "", fmt.Errorf("incorrect number of arguments")
|
||||
}
|
||||
|
||||
// transform the endpoint components, e.g ["helloworld", "call"] to the
|
||||
@@ -383,7 +383,7 @@ func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
}
|
||||
|
||||
result := objx.MustFromJSON("{}")
|
||||
@@ -408,7 +408,7 @@ func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]
|
||||
for key, value := range flags {
|
||||
ty, found := flagType(key, req.Values)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("Unknown flag: %v", key)
|
||||
return nil, fmt.Errorf("unknown flag: %v", key)
|
||||
}
|
||||
parsed, err := coerceValue(ty, value)
|
||||
if err != nil {
|
||||
@@ -425,7 +425,7 @@ func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]
|
||||
}
|
||||
}
|
||||
}
|
||||
path := strings.Replace(key, "-", ".", -1)
|
||||
path := strings.ReplaceAll(key, "-", ".")
|
||||
result.Set(path, parsed)
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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"
|
||||
@@ -27,7 +28,7 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
cmd.Init(
|
||||
_ = cmd.Init(
|
||||
cmd.Name("micro"),
|
||||
cmd.Version(version),
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestParseTool(t *testing.T) {
|
||||
func TestExportFormats(t *testing.T) {
|
||||
// Test that export formats are recognized
|
||||
formats := []string{"langchain", "openapi", "json"}
|
||||
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
@@ -66,7 +66,7 @@ func TestExportFormats(t *testing.T) {
|
||||
func TestDocsFormats(t *testing.T) {
|
||||
// Test that docs formats are recognized
|
||||
formats := []string{"markdown", "json"}
|
||||
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
|
||||
@@ -74,7 +74,7 @@ func brokerSubscribe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fail("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
fmt.Printf("Subscribed to %q (Ctrl-C to stop)...\n", topic)
|
||||
sig := make(chan os.Signal, 1)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user