Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 66d4e6b93e Expose durable agent resume APIs
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-06-29 01:35:51 +00:00
120 changed files with 366 additions and 5904 deletions
+1 -1
View File
@@ -48,4 +48,4 @@ Add any other context about the problem here.
- [Troubleshooting Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
- [Examples](https://github.com/micro/go-micro/tree/master/examples)
- [API Reference](https://pkg.go.dev/go-micro.dev/v5)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
- [Discord Community](https://discord.gg/WeMU5AGxD)
+1 -1
View File
@@ -39,4 +39,4 @@ Add any other context, code examples, or screenshots about the feature request h
- [Roadmap](https://github.com/micro/go-micro/blob/master/ROADMAP.md)
- [Contributing Guide](https://github.com/micro/go-micro/blob/master/CONTRIBUTING.md)
- [Architecture Docs](https://github.com/micro/go-micro/tree/master/internal/website/docs/architecture.md)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
- [Discord Community](https://discord.gg/WeMU5AGxD)
@@ -1,4 +1,4 @@
name: "Loop: Architect (Planner)"
name: Architecture Review
# Continuous high-altitude oversight of the whole framework and harness — the
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
@@ -48,4 +48,4 @@ jobs:
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — DEVELOPER ADOPTION: the framework's depth is strong but its ON-RAMP is the gap, and the strategic priority right now is developer adoption / reviving real usage. Weight the developer on-ramp and DX — a walkable first-agent tutorial, discoverable examples, docs wayfinding/nav, install friction, debugging, the 0→1 and 0→hero experience — AT LEAST as highly as internal hardening. A developer succeeding on their first agent matters more right now than another conformance/observability/interop increment; do NOT let the queue fill entirely with internal depth work — keep open adoption/on-ramp items near the top. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
@@ -1,4 +1,4 @@
name: "Loop: Builder (Generator)"
name: Continuous Improvement
# Durable backbone for the autonomous improvement loop
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
@@ -1,4 +1,4 @@
name: "Loop: DevRel"
name: DevRel Review
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
+23 -37
View File
@@ -12,22 +12,8 @@ on:
pull_request:
branches: ["**"]
schedule:
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
workflow_dispatch:
inputs:
providers:
description: "Comma-separated providers for live conformance (default: all supported)"
required: false
default: "anthropic,openai,gemini,groq,mistral,together,atlascloud"
harnesses:
description: "Comma-separated harnesses for live conformance"
required: false
default: "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
require_configured:
description: "Fail selected live providers that do not have repository secrets"
required: false
type: boolean
default: false
jobs:
harness:
@@ -41,8 +27,14 @@ jobs:
cache: true
- name: Build
run: go build ./...
- name: 0→1 and 0→hero developer-flow harness
run: make harness
- name: 0→1 scaffold contract
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
- name: Universe end-to-end (asserts; exits non-zero on failure)
run: go run ./internal/harness/universe
- name: Agent-flow harness
run: go run ./internal/harness/agent-flow
- name: 0→hero run/chat/inspect reference scenario
run: ./internal/harness/zero-to-hero-ci/run.sh
harness-live:
name: Provider harnesses (live LLM conformance)
@@ -58,6 +50,17 @@ jobs:
with:
go-version: stable
cache: true
- name: Agent provider conformance matrix
env:
GO_MICRO_AGENT_CONFORMANCE_LIVE: "1"
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
run: go test ./agent -run TestAgentProviderConformanceMatrix -count=1 -v
- name: Provider conformance against live models
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -67,28 +70,11 @@ jobs:
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
# Atlas Cloud's default chat model was failing the agent/tool-use
# conformance harnesses; run it against a stronger tool-use model.
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
# catalog id differs (Atlas uses org/model ids).
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
run: |
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
args=(
-providers "$PROVIDERS"
-harnesses "$HARNESSES"
-summary-json provider-conformance-summary.json
-summary-markdown provider-conformance-summary.md
go run ./internal/harness/provider-conformance \
-summary-json provider-conformance-summary.json \
-summary-markdown provider-conformance-summary.md \
-capabilities-markdown provider-capabilities.md
)
if [ "$REQUIRE_CONFIGURED" = "true" ]; then
args+=( -require-configured )
fi
go run ./internal/harness/provider-conformance "${args[@]}"
- name: Publish provider conformance summary
if: always()
run: |
-76
View File
@@ -1,76 +0,0 @@
name: "Loop: Release (daily patch)"
# Keeps the installable framework tracking the loop's daily improvements. Once a
# day, if master has new commits since the latest v6 tag, this cuts the next
# PATCH release (v6.MINOR.PATCH+1) and pushes the tag — which triggers the
# existing goreleaser workflow (release.yml, tag-triggered) to build the release,
# binaries, and images.
#
# The tag is pushed with a PAT (CODEX_TRIGGER_TOKEN), NOT the default GITHUB_TOKEN:
# a tag pushed by GITHUB_TOKEN would not trigger release.yml (Actions blocks that
# recursion). Minor/major bumps stay with the human (notable / breaking releases).
on:
workflow_dispatch: {}
schedule:
- cron: "0 23 * * *" # daily 23:00 UTC — captures the day's merges (tunable)
permissions:
contents: read
concurrency:
group: loop-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
- name: Cut the next patch release if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
echo "A tag pushed by the default GITHUB_TOKEN would not trigger the"
echo "goreleaser workflow, so a user PAT is required to cut releases."
exit 0
fi
git fetch --tags --force
LATEST=$(git tag --list 'v6.*.*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "no v6.x.x tag found — aborting so nothing weird gets tagged."
exit 1
fi
echo "latest release tag: $LATEST"
COUNT=$(git rev-list --count "$LATEST"..HEAD)
echo "commits on HEAD since $LATEST: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "no new commits since $LATEST — no release today."
exit 0
fi
# Bump the patch: v6.MINOR.PATCH -> v6.MINOR.(PATCH+1)
ver="${LATEST#v}" # 6.3.10
major="${ver%%.*}" # 6
rest="${ver#*.}" # 3.10
minor="${rest%%.*}" # 3
patch="${rest#*.}" # 10
case "$major.$minor.$patch" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "go-micro release bot"
git config user.email "noreply@go-micro.dev"
git tag -a "$NEXT" -m "Release $NEXT — automated daily patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT. goreleaser (release.yml) will build and publish it."
-59
View File
@@ -1,59 +0,0 @@
name: "Loop: Triage (Evaluator feedback)"
# Closes the autonomous loop's feedback path: when the live provider-conformance
# harness fails, dispatch Codex to TRIAGE the failing run and file scoped, deduped
# issues that the hourly increment loop then fixes — no human in the middle. It
# only triages the scheduled/manual live run (not every push/PR mock run), dedupes
# against open issues so hourly repeats don't spam, ignores transient flakes, and
# ESCALATES anything needing a breaking/architectural change as needs-human rather
# than auto-building it.
#
# Gated on CODEX_TRIGGER_TOKEN like the rest of the loop (Codex ignores comments
# authored by the github-actions bot).
#
# Note: Codex is serial, so this competes with the hourly increment + architect
# dispatches for the single task slot. If it saturates, lower the harness cadence
# or gate this to a slower schedule.
on:
workflow_run:
workflows: ["Harness (E2E)"]
types: [completed]
permissions:
issues: write
concurrency:
group: harness-triage
cancel-in-progress: false
jobs:
triage:
runs-on: ubuntu-latest
# Only when the harness actually failed, and only for the scheduled or manual
# live run — never the per-push/PR mock run.
if: github.event.workflow_run.conclusion == 'failure' && (github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch')
steps:
- name: Open a triage issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
# Ensure the escalation label exists (idempotent).
gh label create needs-human --repo "$REPO" --color FBCA04 \
--description "Requires a human/architect decision (breaking or architectural)" --force || true
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Harness failure triage: run $RUN_ID" \
--body "Automated triage of a failed live provider-conformance harness run: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened triage issue #$ISSUE_NUM — dispatching Codex."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as failure triage for the autonomous loop. The live provider-conformance harness failed: $RUN_URL (run id $RUN_ID). Do this, and do NOT change code or open a PR — triage only: (1) Read the failing logs (\`gh run view $RUN_ID --repo $REPO --log-failed\`) and the provider-conformance artifact/summary. (2) Root-cause each DISTINCT failure. (3) DEDUPE against existing work — list open issues (\`gh issue list --repo $REPO --label codex --state open --limit 100\`); if a matching issue already exists for a failure, add a one-line 'recurred in $RUN_URL' comment to it and do NOT open a duplicate. (4) For each genuine, self-contained, CI-verifiable defect that is NOT already tracked, open a scoped issue: \`gh issue create --repo $REPO --label codex --label enhancement --title \"<scoped task>\" --body \"<root cause, scope, acceptance criteria, and the failing run link>\"\` — the hourly increment loop will build it. (5) If a failure is transient/flaky and not a code defect (e.g. a live-model latency timeout or provider outage), note it in a comment and file NOTHING. (6) If a real fix would require a breaking public-API change or an architectural change, do NOT file it as an auto-buildable task — open an issue labeled \`needs-human\` describing it for the architect/human. When finished, close this triage issue (\`gh issue close $ISSUE_NUM\`)."
-11
View File
@@ -6,17 +6,6 @@ Thank you for your interest in contributing to Go Micro! This document provides
Be respectful, inclusive, and collaborative. We're all here to build great software together.
## How Go Micro is built
Go Micro is developed by an **autonomous improvement loop** — a planner, a
generator, and a separate evaluator, running as scheduled GitHub Actions with a
human setting direction. It's the framework's own thesis (an agent operating a
system) pointed at itself: an agent harness, built by agents. The full process —
the planner → generator → evaluator pipeline, the correctness-only merge gate, and
the guardrails — is documented in
[`internal/docs/CONTINUOUS_IMPROVEMENT.md`](internal/docs/CONTINUOUS_IMPROVEMENT.md).
Human contributions follow the same gate: green CI, one concern per PR.
## Getting Started
1. Fork the repository
+6 -16
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -18,8 +18,7 @@ help:
@echo " make test-race - Run tests with race detector"
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make harness - Run deterministic end-to-end harnesses"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@echo " make install-tools - Install development tools"
@@ -43,21 +42,12 @@ test-coverage:
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# Run the documented getting-started contracts plus the deterministic
# services → agents → workflows harnesses (mock LLM — no API key).
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
# Run the end-to-end harnesses (deterministic, mock LLM — no API key).
# The universe harness exits non-zero on assertion failure.
harness:
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/universe
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Run the shared provider conformance contract with the deterministic mock
# provider. This is the no-secret path used by CI and local dogfooding to keep
# provider-facing agent/tool semantics covered on every machine.
provider-conformance-mock:
go run ./internal/harness/provider-conformance -providers mock
go run ./internal/harness/plan-delegate # 0→hero: services + agents + flow + plan/delegate
# Run the same harnesses against every configured live provider. Providers
# without API keys are skipped; configured providers must pass.
+4 -15
View File
@@ -1,9 +1,7 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro)
Go Micro is an **agent harness** and service framework for Go.
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
@@ -16,7 +14,7 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
## Commercial Support
@@ -66,14 +64,6 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This scaffold → run → call path is covered by the no-secret CI harness. To run
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### Generate from a prompt — with an LLM key
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
@@ -403,8 +393,8 @@ Swap providers with a single import — same interface everywhere:
| Google Gemini | `gemini-2.5-flash` |
| Groq | `llama-3.3-70b-versatile` |
| Mistral | `mistral-large-latest` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `llama-3.3-70b` |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
@@ -425,7 +415,6 @@ See [all examples](examples/README.md).
- [Getting Started](internal/website/docs/getting-started.md)
- [AI Integration](internal/website/docs/ai-integration.md)
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
- [Agent Design](internal/docs/AGENT_DESIGN.md)
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
+1 -1
View File
@@ -66,7 +66,7 @@ hosted service, enterprise tier, or venture funding. See
## Contributing & feedback
Pick an item, open an issue to discuss the approach, and submit a PR. Or join the
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
[Discord](https://discord.gg/WeMU5AGxD). Include tests, run `make test` and
`make lint`.
## Version support
+1 -1
View File
@@ -174,6 +174,6 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
For security questions that are not vulnerabilities, please:
- Open a discussion: https://github.com/micro/go-micro/discussions
- Join Discord: https://discord.gg/G8Gk5j3uXr
- Join Discord: https://discord.gg/WeMU5AGxD
- Email: support@go-micro.dev
-102
View File
@@ -1,102 +0,0 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
)
func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
var sawTool bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("model was not wired with agent tool handler")
}
result := opts.ToolHandler(ctx, ai.ToolCall{
ID: "call-1",
Name: "echo",
Input: map[string]any{"value": "a2a-stream"},
})
if !strings.Contains(result.Content, "a2a-stream-ok") {
t.Fatalf("tool result = %q, want marker", result.Content)
}
return &ai.Response{Answer: "streamed " + result.Content}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-agent"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if info, ok := ai.RunInfoFrom(ctx); !ok || info.RunID == "" || info.Agent != "stream-agent" {
t.Fatalf("RunInfo = %+v ok=%v, want stream-agent run", info, ok)
}
if input["value"] != "a2a-stream" {
t.Fatalf("tool input = %+v, want a2a-stream", input)
}
return "a2a-stream-ok", nil
}))
h := a2a.NewAgentStreamHandler(
a2a.Card("stream-agent", "http://example.invalid/stream-agent", "", nil),
func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
},
a.streamAskAI,
)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"run stream tool"}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if !sawTool {
t.Fatal("A2A stream did not execute the agent tool path")
}
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
if !strings.Contains(rr.Body.String(), "a2a-stream-ok") {
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
}
var final struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
Error any `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &final); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
}
if final.Error != nil {
t.Fatalf("final event error: %+v", final.Error)
}
if final.Result.Status.State != "completed" {
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
}
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
}
}
+10 -50
View File
@@ -19,7 +19,6 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
@@ -140,35 +139,19 @@ func (a *agentImpl) String() string {
}
func (a *agentImpl) setup() {
a.setupWithToolHandler(nil)
}
func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
// already captured the old instance (breaking StreamAsk tool resolution).
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
if handler == nil {
handler = a.toolHandler()
}
modelOpts = append(modelOpts, ai.WithToolHandler(handler))
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
a.model = ai.New(a.opts.Provider, modelOpts...)
if a.model != nil {
a.model = a.tracedModel(a.model)
}
if a.mem != nil {
return
}
// Memory is pluggable. Use the configured one, otherwise the default
// store-backed memory — except ephemeral sub-agents, which keep an
// isolated, non-persistent context.
@@ -178,9 +161,7 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
@@ -244,18 +225,16 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
if addUserMessage {
a.mem.Add("user", message)
}
a.mem.Add("user", message)
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
@@ -274,9 +253,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
return nil, err
}
ctx, endRun := a.startRun(ctx, message)
if existing != nil {
a.recordTimelineEvent(ctx, RunEvent{Time: time.Now(), RunID: runID, ParentID: parentRunID, Agent: a.opts.Name, Kind: "resume", Name: run.State.Stage})
}
defer func() { endRun(err) }()
messages := a.mem.Messages()
@@ -300,15 +276,12 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
run.Status = "failed"
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
@@ -329,19 +302,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
}
}
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
@@ -441,7 +401,7 @@ func (a *agentImpl) Run() error {
return "", err
}
return resp.Reply, nil
}, a.streamAskAI)
}, a.Stream)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
+1 -116
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
@@ -114,15 +113,13 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
// prevents runaway recursion).
func (a *agentImpl) toolHandler() ai.ToolHandler {
if a.ephemeral {
return a.toolTimeoutWrap(a.tools.Handler())
return a.tools.Handler()
}
// Innermost first: base, then guardrails (approve → loop → step →
// plan), then developer wrappers outermost. Wrapping reverses order,
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.toolTimeoutWrap(h)
h = a.toolRetryWrap(h)
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.loopWrap(h)
@@ -151,114 +148,6 @@ func contextWrap(next ai.ToolHandler) ai.ToolHandler {
}
}
// toolTimeoutWrap gives each tool execution its own deadline while preserving
// caller cancellation. Handlers still execute synchronously; tools that honor
// context (custom tools, delegate RPC/A2A, and go-micro RPC clients) return
// promptly with a bounded error result when the deadline expires.
func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.ToolTimeout <= 0 {
return next(ctx, call)
}
toolCtx, cancel := context.WithTimeout(ctx, a.opts.ToolTimeout)
defer cancel()
return next(toolCtx, call)
}
}
// toolRetryWrap retries transient tool failures with bounded backoff. It is
// opt-in because tools can have side effects; guardrail refusals and caller
// cancellation are never retried.
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
maxAttempts := a.opts.ToolMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
var res ai.ToolResult
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return errResult(call.ID, err.Error())
}
res = next(ctx, call)
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
return annotateToolAttempts(res, attempt)
}
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
select {
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return errResult(call.ID, ctx.Err().Error())
case <-t.C:
}
}
return annotateToolAttempts(res, maxAttempts)
}
}
func retryableToolResult(res ai.ToolResult) bool {
if res.Refused != "" {
return false
}
msg := toolErrorMessage(res)
if msg == "" {
return false
}
return ai.IsTransientError(fmt.Errorf("%s", msg))
}
func toolErrorMessage(res ai.ToolResult) string {
if m, ok := res.Value.(map[string]string); ok {
return m["error"]
}
if m, ok := res.Value.(map[string]any); ok {
if v, ok := m["error"].(string); ok {
return v
}
}
var decoded map[string]string
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
return decoded["error"]
}
return ""
}
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
if attempts <= 1 {
return res
}
res.Attempts = attempts
if m, ok := res.Value.(map[string]string); ok {
cp := map[string]any{}
for k, v := range m {
cp[k] = v
}
cp["attempts"] = attempts
res.Value = cp
if b, err := json.Marshal(cp); err == nil {
res.Content = string(b)
}
}
return res
}
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
if base <= 0 {
base = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
base <<= shift
}
if base > 30*time.Second {
return 30 * time.Second
}
return base
}
// baseHandler executes a tool call: a developer custom tool, the built-in
// delegate, or an RPC to the service. It is the innermost handler.
func (a *agentImpl) baseHandler() ai.ToolHandler {
@@ -430,10 +319,6 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
WithRegistry(a.opts.Registry),
WithClient(a.opts.Client),
WithStore(a.opts.Store),
ModelCallTimeout(a.opts.ModelTimeout),
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
ToolCallTimeout(a.opts.ToolTimeout),
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
TraceProvider(a.opts.TraceProvider),
)
// Record lineage so the sub-agent's tool calls carry this run as parent.
+3 -34
View File
@@ -49,12 +49,6 @@ func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
}
if info, ok := ai.RunInfoFrom(ctx); ok {
a.recordTimelineEvent(ctx, RunEvent{
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
Kind: "checkpoint", Name: run.State.Stage, Status: run.Status,
})
}
return nil
}
@@ -94,9 +88,6 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, fmt.Errorf("agent run %s is terminal with status %q", runID, run.Status)
}
message := string(run.State.Data)
parentID := run.ParentID
a.mu.Lock()
@@ -104,7 +95,7 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, parentID, &run, false)
return a.askLocked(ctx, run.ID, message, parentID, &run)
}
// ResumeInput resumes a checkpointed agent run that paused via the built-in
@@ -149,7 +140,7 @@ func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Resp
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
return a.askLocked(ctx, run.ID, message, run.ParentID, &run)
}
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
@@ -162,35 +153,13 @@ func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
}
out := runs[:0]
for _, run := range runs {
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
if run.Flow == a.opts.Name && run.Status != "done" {
out = append(out, run)
}
}
return out, nil
}
func terminalAgentRunStatus(status string) bool {
switch status {
case "done", "canceled", "timeout", "rate_limited", "expired":
return true
default:
return false
}
}
func agentRunFailureStatus(err error) string {
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "failed"
}
}
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Checkpoint == nil || a.currentRun == nil {
+5 -214
View File
@@ -13,7 +13,7 @@ import (
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "durable-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
@@ -52,7 +52,7 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-resume-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "tool-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
@@ -102,144 +102,9 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
toolRuns := 0
modelCalls := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
if res.Content != "provisioned" {
t.Fatalf("tool result = %q, want provisioned", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped after tool checkpoint")
}
return &ai.Response{Reply: "resumed after restart"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "provisioned", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "provision api")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, first)
if err != nil {
t.Fatalf("Pending before restart: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
}
restarted := newAgent()
resp, err := Resume(ctx, restarted, runs[0].ID)
if err != nil {
t.Fatalf("Resume after restart: %v", err)
}
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
}
if toolRuns != 1 {
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "memory-resume-agent")
failRetry := true
var sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, "alpha code is 42") {
sawRecall = true
}
}
if strings.Contains(req.Prompt, "use alpha code") && failRetry {
failRetry = false
return nil, errors.New("model connection dropped")
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory-resume-agent"), WithStore(st), WithCheckpoint(cp), CompactMemory(4, 1), MemoryRecallLimit(2))
for _, msg := range []string{"alpha code is 42", "beta note", "gamma note"} {
if _, err := a.Ask(ctx, msg); err != nil {
t.Fatalf("Ask(%q): %v", msg, err)
}
}
_, err := a.Ask(ctx, "use alpha code now")
if err == nil {
t.Fatal("Ask succeeded, want simulated provider failure")
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("failed Ask stored prompt %d times, want 1", got)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
if _, err := Resume(ctx, a, runs[0].ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("resumed failed Ask stored prompt %d times, want no duplicate", got)
}
if !sawRecall {
t.Fatal("resume did not retrieve archived compacted memory")
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages after resume, want <= 4", got)
}
}
func countMemoryContent(messages []ai.Message, needle string) int {
var count int
for _, msg := range messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, needle) {
count++
}
}
return count
}
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "pending-agent")
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save: %v", err)
@@ -254,38 +119,9 @@ func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
}
}
func TestPendingSkipsTerminalCanceledAndExpiredAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-agent")
for _, run := range []flow.Run{
{ID: "active", Flow: "terminal-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}},
{ID: "done", Flow: "terminal-agent", Status: "done", State: flow.State{Stage: agentAskStep, Data: []byte("done")}},
{ID: "canceled", Flow: "terminal-agent", Status: "canceled", State: flow.State{Stage: agentAskStep, Data: []byte("canceled")}},
{ID: "expired", Flow: "terminal-agent", Status: "expired", State: flow.State{Stage: agentAskStep, Data: []byte("expired")}},
} {
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
a := newTestAgent(Name("terminal-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "active" {
t.Fatalf("Pending = %#v, want only active failed run", runs)
}
for _, id := range []string{"canceled", "expired"} {
if _, err := Resume(ctx, a, id); err == nil || !strings.Contains(err.Error(), "terminal") {
t.Fatalf("Resume(%s) err = %v, want terminal status error", id, err)
}
}
}
func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "input-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
@@ -341,54 +177,9 @@ func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
}
}
func TestHumanInputResumeHonorsCanceledContextAndLeavesRunPending(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-cancel-agent")
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Approve deploy?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-cancel-agent"), WithCheckpoint(cp))
if _, err := a.Ask(ctx, "deploy the service"); err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := ResumeInput(canceled, a, runs[0].ID, "yes"); !errors.Is(err, context.Canceled) {
t.Fatalf("ResumeInput canceled err = %v, want context.Canceled", err)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load paused run ok=%v err=%v", ok, err)
}
if loaded.Status != "paused" || loaded.State.Stage != agentInputStep {
t.Fatalf("run status/stage after canceled resume = %s/%s, want paused/%s", loaded.Status, loaded.State.Stage, agentInputStep)
}
var pause inputPause
if err := loaded.State.Scan(&pause); err != nil {
t.Fatalf("Scan pause after canceled resume: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Approve deploy?" {
t.Fatalf("pause after canceled resume = %#v", pause)
}
}
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "approval-agent")
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
-62
View File
@@ -33,29 +33,14 @@ func TestAgentProviderConformanceMatrix(t *testing.T) {
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
provider := provider
if len(selected) > 0 && !selected[provider.name] {
continue
}
t.Run(provider.name, func(t *testing.T) {
runAgentConformanceScenario(t, provider)
})
}
}
func selectedConformanceProviders(csv string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(csv, ",") {
part = strings.TrimSpace(part)
if part != "" {
out[part] = true
}
}
return out
}
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
@@ -171,50 +156,3 @@ func TestAgentProviderConformanceFakeError(t *testing.T) {
t.Fatalf("Ask error = %v, want conformance provider failure", err)
}
}
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
return &ai.Response{
Reply: `{"name":"conformance_echo","input":{"value":"agent-conformance"}}`,
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-text-tool"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the text tool call fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawTool {
t.Fatal("text tool call fallback did not execute the tool")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
if strings.Contains(resp.Reply, `"name":"conformance_echo"`) {
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
}
}
+9 -55
View File
@@ -24,12 +24,6 @@ type Memory interface {
Clear()
}
// MemorySummaryFunc turns older conversation messages into a compact
// replacement message for active context. It is called while the default
// memory is locked, so implementations should be deterministic and avoid
// calling back into the same memory instance.
type MemorySummaryFunc func([]ai.Message) ai.Message
// MemoryCompaction configures deterministic, store-backed context compaction
// for the default memory implementation. When the retained conversation grows
// past MaxMessages, older turns are collapsed into a summary message while the
@@ -37,7 +31,6 @@ type MemorySummaryFunc func([]ai.Message) ai.Message
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
Summarize MemorySummaryFunc
}
// MemoryRecall is implemented by memory backends that can retrieve durable
@@ -56,29 +49,11 @@ func NewMemory(s store.Store, key string, limit int) Memory {
return m
}
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
// conversation and archives every turn for retrieval. It is useful when callers
// want relevant durable recall without summary compaction in the active context.
// A nil store or empty key keeps only the active in-process buffer.
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
m.load()
return m
}
// NewCompactingMemory returns store-backed memory with explicit compaction and
// retrieval controls. It keeps all messages in the backing store, compacts older
// turns into a deterministic summary when the conversation exceeds maxMessages,
// and lets callers recall relevant prior turns with Recall.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
return NewCompactingMemoryWithOptions(s, key, MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent})
}
// NewCompactingMemoryWithOptions returns store-backed memory configured with
// explicit compaction options, including an optional summarization hook.
func NewCompactingMemoryWithOptions(s store.Store, key string, compaction MemoryCompaction) Memory {
maxMessages := compaction.MaxMessages
keepRecent := compaction.KeepRecent
if keepRecent <= 0 {
keepRecent = maxMessages / 2
}
@@ -94,7 +69,6 @@ func NewCompactingMemoryWithOptions(s store.Store, key string, compaction Memory
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
Summarize: compaction.Summarize,
},
}
m.load()
@@ -110,20 +84,16 @@ func NewInMemory(limit int) Memory {
// storeMemory is the default Memory: an ai.History buffer optionally
// persisted to a store.
type storeMemory struct {
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
retrieveAll bool
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
if m.retrieveAll {
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
}
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
@@ -147,8 +117,6 @@ func (m *storeMemory) Clear() {
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
// When created with NewRetrievalMemory the archive contains every persisted
// turn; when created with NewCompactingMemory it contains compacted older turns.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
@@ -202,9 +170,6 @@ func (m *storeMemory) load() {
}
m.mu.Lock()
m.archive = state.Archive
if m.retrieveAll && len(m.archive) == 0 {
m.archive = append(m.archive, state.Messages...)
}
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
@@ -248,13 +213,9 @@ func (m *storeMemory) compact() {
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summarize := m.compaction.Summarize
if summarize == nil {
summarize = defaultMemorySummary
}
summary := summarize(older)
if summary.Role == "" {
summary.Role = "system"
summary := ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
@@ -263,13 +224,6 @@ func (m *storeMemory) compact() {
}
}
func defaultMemorySummary(msgs []ai.Message) ai.Message {
return ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(msgs)),
}
}
func summarizeMessages(msgs []ai.Message) string {
var b strings.Builder
for i, msg := range msgs {
-75
View File
@@ -3,11 +3,9 @@ package agent
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
@@ -64,49 +62,6 @@ func TestWithMemoryUsed(t *testing.T) {
}
}
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta owner is lee")
m.Add("assistant", "tracked")
m.Add("user", "alpha owner is sam")
if got := len(m.Messages()); got != 2 {
t.Fatalf("active messages = %d, want bounded history of 2", got)
}
recall, ok := m.(MemoryRecall)
if !ok {
t.Fatal("retrieval memory should support recall")
}
recalled := recall.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected relevant recalled turns")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
}
}
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
writer := m.(Memory)
@@ -147,36 +102,6 @@ func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
}
}
func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{
MaxMessages: 3,
KeepRecent: 1,
Summarize: func(msgs []ai.Message) ai.Message {
return ai.Message{Role: "system", Content: "custom summary count=" + strconv.Itoa(len(msgs))}
},
})
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
msgs := m.Messages()
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
}
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
recall := reloaded.(MemoryRecall)
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
// A custom tool is offered to the model and dispatched to its handler.
func TestWithToolExposedAndDispatched(t *testing.T) {
var got map[string]any
+1 -69
View File
@@ -56,23 +56,10 @@ type Options struct {
// ModelRetryBackoff is the base delay between transient provider failures
// (grows exponentially per attempt when retries are enabled).
ModelRetryBackoff time.Duration
// ToolTimeout bounds each tool execution (0 disables). The timeout is
// applied before custom tools, delegate, and service RPC calls so context
// deadlines propagate consistently through the agent loop.
ToolTimeout time.Duration
// ToolMaxAttempts bounds tool execution attempts including the first call.
// Default 1; retries are opt-in because tools can have side effects.
ToolMaxAttempts int
// ToolRetryBackoff is the base delay between transient tool failures.
ToolRetryBackoff time.Duration
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryRetrievalLimit enables retrieval-backed default memory without
// compaction. The active conversation stays bounded to this many messages
// while every turn is archived for deterministic recall.
MemoryRetrievalLimit int
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
@@ -104,11 +91,6 @@ type Options struct {
// and tool calls. Nil disables instrumentation.
TraceProvider trace.TracerProvider
// TraceInputs controls whether agent observability records include raw
// user messages. It is false by default so spans and persisted run
// timelines carry correlation and shape without leaking prompts.
TraceInputs bool
// tools are developer-registered custom tools (see WithTool).
tools []customTool
// wrappers are developer-registered tool-execution wrappers
@@ -125,9 +107,6 @@ func newOptions(opts ...Option) Options {
ModelTimeout: 30 * time.Second,
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
ModelRetryBackoff: 100 * time.Millisecond,
ToolTimeout: 30 * time.Second,
ToolMaxAttempts: 1,
ToolRetryBackoff: 100 * time.Millisecond,
// On by default and lenient: identical repeated calls are a
// no-progress loop, never useful. Set LoopLimit(0) to disable.
LoopLimit: 3,
@@ -220,14 +199,6 @@ func ModelCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ModelTimeout = d }
}
// ToolCallTimeout sets the timeout for each tool execution. It bounds custom
// tools, built-in delegate calls, and service RPC tools with the same context
// deadline so mid-run cancellation and slow tools produce safe error results
// instead of unbounded agent runs. Set 0 to disable.
func ToolCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ToolTimeout = d }
}
// ModelRetry sets the provider retry budget and backoff for transient failures.
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
@@ -236,16 +207,6 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
}
}
// ToolRetry sets the tool retry budget and backoff for transient failures.
// Attempts include the first call. Retries are opt-in because tools may have
// side effects; keep handlers idempotent before enabling this.
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ToolMaxAttempts = maxAttempts
o.ToolRetryBackoff = backoff
}
}
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
// ":4000"), so other agents can reach it directly by URL without a
// separate gateway. The agent stays a normal go-micro service as well;
@@ -261,40 +222,19 @@ func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// RetrievalMemory enables deterministic, store-backed retrieval memory for
// the default agent memory without compaction. Active context is capped at
// activeLimit messages while every turn is archived in the store for Recall.
func RetrievalMemory(activeLimit int) Option {
return func(o *Options) {
o.MemoryRetrievalLimit = activeLimit
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// CompactMemory enables deterministic, store-backed memory compaction for the
// default agent memory. Older turns are summarized once active context exceeds
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
// turns are injected into matching future asks.
func CompactMemory(maxMessages, keepRecent int) Option {
return func(o *Options) {
o.MemoryCompaction.MaxMessages = maxMessages
o.MemoryCompaction.KeepRecent = keepRecent
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent}
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// MemorySummarizer sets the deterministic summarization hook used by the
// default compacting memory. It is optional; without it, compacted memory uses
// a provider-neutral text summary. The hook receives the older messages being
// removed from active context and returns the replacement summary message.
func MemorySummarizer(fn MemorySummaryFunc) Option {
return func(o *Options) { o.MemoryCompaction.Summarize = fn }
}
// MemoryRecallLimit sets how many archived turns a memory backend may inject
// into a model request for the current Ask. Use 0 to disable retrieval.
func MemoryRecallLimit(n int) Option {
@@ -355,11 +295,3 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) { o.TraceProvider = tp }
}
// TraceInputs opts in to recording raw user messages on agent run events.
// By default inputs are redacted from OpenTelemetry spans and persisted run
// timelines; use this only when the observability backend is approved to store
// prompt content.
func TraceInputs(enabled bool) Option {
return func(o *Options) { o.TraceInputs = enabled }
}
+65 -174
View File
@@ -22,51 +22,36 @@ const (
spanNameModelCall = "agent.model.call"
spanNameToolCall = "agent.tool.call"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrAttempt = "agent.model.attempt"
AttrMaxAttempts = "agent.model.max_attempts"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
AttrInputChars = "agent.input.chars"
AttrErrorKind = "agent.error.kind"
AttrCheckpointStatus = "agent.checkpoint.status"
AttrCheckpointStage = "agent.checkpoint.stage"
AttrFlowName = "agent.flow.name"
AttrFlowStep = "agent.flow.step"
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
)
type RunEvent struct {
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"max_attempts,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
InputChars int `json:"input_chars,omitempty"`
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Error string `json:"error,omitempty"`
}
type Usage = ai.Usage
@@ -75,8 +60,7 @@ type Usage = ai.Usage
// Zero values preserve the full deterministic run list.
type RunListOptions struct {
// Status, when set, keeps only runs with the matching status
// (for example "running", "done", "canceled", "timeout",
// "rate_limited", "error", or "refused").
// (for example "running", "done", "error", or "refused").
Status string
// TraceID, when set, keeps only runs correlated with this trace id.
// A prefix is accepted so operators can paste the shortened trace id
@@ -89,19 +73,18 @@ type RunListOptions struct {
// RunSummary is a compact index entry for a recorded agent run.
type RunSummary struct {
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
DurationMS int64 `json:"duration_ms,omitempty"`
Events int `json:"events"`
Status string `json:"status,omitempty"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
DurationMS int64 `json:"duration_ms,omitempty"`
Events int `json:"events"`
Status string `json:"status,omitempty"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
}
func (a *agentImpl) tracer() trace.Tracer {
@@ -111,38 +94,29 @@ func (a *agentImpl) tracer() trace.Tracer {
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, func(error)) {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
runEvent := RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", InputChars: len(message)}
if a.opts.TraceInputs {
runEvent.Name = message
}
if a.opts.TraceProvider == nil {
a.recordRunEvent(runEvent)
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
if err != nil {
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
return
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
}, info)
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
a.recordSpanEvent(span, runEvent)
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
} else {
span.SetStatus(codes.Ok, "")
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
@@ -170,32 +144,24 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
if resp != nil {
usage = resp.Usage
}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordRunEvent(e)
return resp, err
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
))
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
}
if info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, info.MaxAttempts))
}
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
@@ -203,17 +169,15 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
}
span.SetAttributes(attrs...)
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordSpanEvent(span, e)
return resp, err
@@ -240,8 +204,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
if a.opts.TraceProvider == nil {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
resErr := resultError(res)
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resultError(res)})
return res
}
@@ -258,11 +221,8 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
resErr := resultError(res)
if kind := classifyToolError(resErr); kind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, kind))
}
span.SetAttributes(attrs...)
resErr := resultError(res)
if res.Refused != "" {
span.SetStatus(codes.Error, res.Refused)
} else if resErr != "" {
@@ -271,7 +231,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
span.SetStatus(codes.Ok, "")
}
span.End()
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr})
return res
}
}
@@ -288,28 +248,6 @@ func resultError(res ai.ToolResult) string {
return ""
}
func classifyToolError(err string) string {
switch {
case err == "":
return ""
case strings.Contains(strings.ToLower(err), "context canceled"):
return string(ai.ErrorKindCanceled)
case strings.Contains(strings.ToLower(err), "deadline exceeded"):
return string(ai.ErrorKindTimeout)
default:
return string(ai.ErrorKindProvider)
}
}
func (a *agentImpl) recordTimelineEvent(ctx context.Context, e RunEvent) {
span := trace.SpanFromContext(ctx)
if span.SpanContext().IsValid() {
a.recordSpanEvent(span, e)
return
}
a.recordRunEvent(e)
}
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
if sc := span.SpanContext(); sc.IsValid() {
e.TraceID = sc.TraceID().String()
@@ -336,18 +274,9 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
if e.Model != "" {
attrs = append(attrs, attribute.String(AttrModel, e.Model))
}
if e.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
}
if e.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
}
if e.LatencyMS > 0 {
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
}
if e.InputChars > 0 {
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
}
attrs = appendUsage(attrs, e.Tokens)
if e.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
@@ -355,33 +284,6 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
if e.Error != "" {
attrs = append(attrs, attribute.String("agent.error", e.Error))
}
if e.ErrorKind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
}
if e.Kind == "checkpoint" {
if e.Status != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStatus, e.Status))
}
if e.Name != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStage, e.Name))
}
}
return attrs
}
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Flow != "" {
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
}
if info.Step != "" {
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
}
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
}
return attrs
}
@@ -461,9 +363,6 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
if e.Error != "" {
summary.LastError = e.Error
}
if e.ErrorKind != "" {
summary.LastErrorKind = e.ErrorKind
}
}
if opts.Status != "" && summary.Status != opts.Status {
continue
@@ -490,32 +389,24 @@ func runStatus(events []RunEvent) string {
}
status := "running"
for _, e := range events {
if e.Refused != "" && status == "running" {
if e.Error != "" {
status = "error"
}
if e.Refused != "" && status != "error" {
status = "refused"
}
if e.Error != "" || e.Kind == "error" {
status = runErrorStatus(e.ErrorKind)
}
if e.Kind == "done" && status == "running" {
status = "done"
switch e.Kind {
case "error":
status = "error"
case "done":
if status == "running" {
status = "done"
}
}
}
return status
}
func runErrorStatus(kind string) string {
switch ai.ErrorKind(kind) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "error"
}
}
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
+5 -245
View File
@@ -3,23 +3,18 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
const codesError = codes.Error
type otelTestModel struct{ opts ai.Options }
func (m *otelTestModel) Init(opts ...ai.Option) error {
@@ -94,9 +89,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
}
if s.Name() == spanNameModelCall && (attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1") {
t.Fatalf("model span missing attempt attributes: %#v", attrs)
}
}
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
if err != nil {
@@ -133,146 +125,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
}
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
secret := "deploy production with token sk-secret"
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("redactor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), secret); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawInputChars bool
for _, s := range spans {
for _, event := range s.Events() {
attrs := spanAttributes(event.Attributes)
if attrs["agent.event.name"] == secret {
t.Fatalf("span event leaked raw input: %#v", event)
}
if attrs[AttrInputChars] == fmt.Sprint(len(secret)) {
sawInputChars = true
}
}
}
if !sawInputChars {
t.Fatal("run event missing redacted input length attribute")
}
summaries, err := ListRunSummaries(st, "redactor")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "redactor", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Name == secret {
t.Fatalf("persisted run event leaked raw input: %#v", event)
}
if event.Kind == "run" && event.InputChars != len(secret) {
t.Fatalf("run event InputChars = %d, want %d", event.InputChars, len(secret))
}
}
}
func TestAgentTraceInputsOptInRecordsInput(t *testing.T) {
message := "operator-approved diagnostic prompt"
st := store.NewMemoryStore()
a := New(Name("input-opt-in"), Provider("oteltest"), WithStore(st), TraceInputs(true))
if _, err := a.Ask(context.Background(), message); err != nil {
t.Fatal(err)
}
summaries, err := ListRunSummaries(st, "input-opt-in")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "input-opt-in", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Kind == "run" && event.Name == message {
return
}
}
t.Fatalf("opt-in run event did not record message: %#v", events)
}
type failingOtelModel struct{ opts ai.Options }
func (m *failingOtelModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *failingOtelModel) Options() ai.Options { return m.opts }
func (m *failingOtelModel) String() string { return "otelfail" }
func (m *failingOtelModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *failingOtelModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return nil, errors.New("provider exploded")
}
func init() {
ai.Register("otelfail", func(opts ...ai.Option) ai.Model { return &failingOtelModel{opts: ai.NewOptions(opts...)} })
}
func TestAgentOpenTelemetrySpansModelFailure(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("failing-runner"), Provider("otelfail"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "hello"); err == nil {
t.Fatal("Ask succeeded, want provider error")
}
spans := exp.GetSpans().Snapshots()
var sawRunError, sawModelError bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
switch s.Name() {
case spanNameRun:
if attrs[AttrAgentName] == "failing-runner" && s.Status().Code == codesError {
sawRunError = true
}
case spanNameModelCall:
if attrs[AttrAgentName] == "failing-runner" && attrs[AttrAttempt] == "1" && attrs[AttrErrorKind] == string(ai.ErrorKindUnknown) && s.Status().Code == codesError {
sawModelError = true
}
}
}
if !sawRunError || !sawModelError {
t.Fatalf("missing error spans: run=%v model=%v spans=%d", sawRunError, sawModelError, len(spans))
}
summaries, err := ListRunSummaries(st, "failing-runner")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 || summaries[0].Status != "error" || summaries[0].LastError == "" {
t.Fatalf("unexpected failure summary: %#v", summaries)
}
events, err := LoadRunEvents(st, "failing-runner", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
var sawModelEvent bool
for _, event := range events {
if event.Kind == "model" && event.Attempt == 1 && event.MaxAttempts == 1 && event.Error != "" && event.ErrorKind == string(ai.ErrorKindUnknown) {
sawModelEvent = true
}
}
if !sawModelEvent {
t.Fatalf("missing failed model event with attempt metadata: %#v", events)
}
}
func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
for _, event := range events {
if event.Name != name {
@@ -389,74 +241,6 @@ func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
}
}
func TestAgentCheckpointAndResumeTimelineEvents(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "resume-otel-agent")
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if first {
first = false
return nil, errors.New("temporary provider failure")
}
return &ai.Response{Reply: "resumed"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("resume-otel-agent"), WithStore(st), WithCheckpoint(cp), TraceProvider(tp))
_, err := a.Ask(context.Background(), "resume me")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
resp, err := Resume(context.Background(), a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "resumed" {
t.Fatalf("reply = %q, want resumed", resp.Reply)
}
events, err := LoadRunEvents(st, "resume-otel-agent", runs[0].ID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"checkpoint": false, "resume": false}
for _, e := range events {
if _, ok := seen[e.Kind]; ok {
seen[e.Kind] = true
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
var resumeSpanEvent bool
for _, s := range exp.GetSpans().Snapshots() {
if s.Name() != spanNameRun {
continue
}
for _, e := range s.Events() {
if e.Name == "agent.resume" {
resumeSpanEvent = true
}
}
}
if !resumeSpanEvent {
t.Fatal("run span missing agent.resume event")
}
}
func TestLoadRunEventsSortsTimelineKeys(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
@@ -498,7 +282,7 @@ func TestListRunSummaries(t *testing.T) {
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -521,35 +305,11 @@ func TestListRunSummaries(t *testing.T) {
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
t.Fatalf("unexpected run-a summary: %#v", got[0])
}
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "error" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "boom" {
t.Fatalf("unexpected run-b summary: %#v", got[1])
}
}
func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
tests := []struct {
name string
kind ai.ErrorKind
want string
}{
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runStatus([]RunEvent{
{Kind: "run"},
{Kind: "error", Error: "failed", ErrorKind: string(tt.kind)},
})
if got != tt.want {
t.Fatalf("runStatus() = %q, want %q", got, tt.want)
}
})
}
}
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
@@ -557,7 +317,7 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "rate limit exceeded", ErrorKind: string(ai.ErrorKindRateLimited)},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -569,11 +329,11 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
}
}
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "rate_limited", TraceID: "abcdef", Limit: 1})
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "error", TraceID: "abcdef", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "rate_limited" {
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "error" {
t.Fatalf("filtered summaries = %#v", got)
}
}
-127
View File
@@ -8,8 +8,6 @@ import (
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
@@ -104,128 +102,3 @@ func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
t.Fatalf("plan persisted after canceled tool context: %q", plan)
}
}
func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
var sawDeadline bool
a := newTestAgent(
Name("tool-timeout"),
ToolCallTimeout(10*time.Millisecond),
WithTool("slow", "slow tool", nil, func(ctx context.Context, input map[string]any) (string, error) {
if _, ok := ctx.Deadline(); ok {
sawDeadline = true
}
<-ctx.Done()
return "", ctx.Err()
}),
)
start := time.Now()
content := toolContent(a.toolHandler(), "slow", nil)
if !sawDeadline {
t.Fatal("custom tool did not receive a deadline")
}
if !strings.Contains(content, context.DeadlineExceeded.Error()) {
t.Fatalf("tool result = %q, want deadline exceeded", content)
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("tool call took %s, want bounded timeout", elapsed)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "canceled", err: context.Canceled, want: "canceled"},
{name: "timeout", err: context.DeadlineExceeded, want: "timeout"},
{name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-"))
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, tt.err
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp))
_, err := a.Ask(context.Background(), "fail safely")
if err == nil {
t.Fatal("Ask succeeded, want failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
if runs[0].Status != tt.want {
t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want)
}
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
}
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
}
})
}
}
type testStatusError struct {
code int
}
func (e testStatusError) Error() string { return "provider status error" }
func (e testStatusError) StatusCode() int { return e.code }
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-success"),
ToolRetry(3, time.Millisecond),
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
if attempts < 3 {
return "", context.DeadlineExceeded
}
return "ok", nil
}),
)
content := toolContent(a.toolHandler(), "flaky", nil)
if content != "ok" {
t.Fatalf("tool result = %q, want ok", content)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-refusal"),
MaxSteps(1),
ToolRetry(3, time.Millisecond),
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
return "ok", nil
}),
)
h := a.toolHandler()
_ = toolContent(h, "counted", nil)
content := toolContent(h, "counted", nil)
if !strings.Contains(content, "step limit reached") {
t.Fatalf("tool result = %q, want step-limit refusal", content)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
}
}
-277
View File
@@ -1,277 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync"
"github.com/google/uuid"
"go-micro.dev/v6/ai"
)
// StreamEventType identifies an event emitted by a tool-aware agent stream.
type StreamEventType string
const (
// StreamEventToolStart is emitted immediately before a tool call runs.
StreamEventToolStart StreamEventType = "tool_start"
// StreamEventToolEnd is emitted after a tool call returns or is refused.
StreamEventToolEnd StreamEventType = "tool_end"
// StreamEventToken carries a chunk of the final answer.
StreamEventToken StreamEventType = "token"
// StreamEventDone carries the completed agent response.
StreamEventDone StreamEventType = "done"
)
// StreamEvent is one event from StreamAsk.
type StreamEvent struct {
Type StreamEventType
Token string
ToolCall ai.ToolCall
Result ai.ToolResult
Response *Response
}
// AgentStream is a stream of tool execution events followed by final-answer chunks.
type AgentStream interface {
Recv() (*StreamEvent, error)
Close() error
}
// StreamAsk runs an agent Ask turn with tool start/end events and streams the final answer.
// It is additive for callers that hold the public Agent interface; concrete agents also
// expose the same method directly.
func StreamAsk(ctx context.Context, ag Agent, message string) (AgentStream, error) {
streamer, ok := ag.(interface {
StreamAsk(context.Context, string) (AgentStream, error)
})
if !ok {
return nil, errors.New("agent: StreamAsk unsupported by implementation")
}
return streamer.StreamAsk(ctx, message)
}
// ResumeStreamAsk resumes a checkpointed agent run and emits the same event
// shape as StreamAsk. Completed runs are streamed from the persisted response;
// unfinished runs continue from their checkpoint and emit tool events for any
// work that still needs to run. Tool calls already recorded as done in the
// checkpoint are reused by the agent checkpoint wrapper and are not re-executed.
func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, errors.New("agent: ResumeStreamAsk unsupported by implementation")
}
return a.resumeStreamAsk(ctx, runID)
}
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
// then emits chunks of the final answer followed by a Done event.
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.askWithStreamEvents(ctx, message, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) askWithStreamEvents(ctx context.Context, message string, events chan<- *StreamEvent) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
return a.askLocked(ctx, uuid.New().String(), message, a.parentRunID, nil, true)
}
func (a *agentImpl) resumeWithStreamEvents(ctx context.Context, runID string, events chan<- *StreamEvent) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, errors.New("agent: ResumeStreamAsk requires a checkpoint")
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("agent: checkpointed run not found")
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, errors.New("agent: checkpointed run is terminal with status " + run.Status)
}
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, errors.New("agent: checkpointed run is input-required; resume with ResumeInput")
}
run.Status = "running"
run.State.Stage = agentAskStep
}
return a.askLocked(ctx, run.ID, string(run.State.Data), run.ParentID, &run, false)
}
type agentStreamAdapter struct {
stream AgentStream
}
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
if err != nil {
return nil, err
}
if event == nil {
continue
}
switch event.Type {
case StreamEventToken:
if event.Token == "" {
continue
}
return &ai.Response{Reply: event.Token}, nil
case StreamEventDone:
return nil, io.EOF
}
}
}
func (s *agentStreamAdapter) Close() error {
return s.stream.Close()
}
func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream, error) {
stream, err := a.StreamAsk(ctx, message)
if err != nil {
return nil, err
}
return &agentStreamAdapter{stream: stream}, nil
}
type agentStream struct {
events <-chan *StreamEvent
done <-chan struct{}
mu sync.Mutex
err error
}
func (s *agentStream) Recv() (*StreamEvent, error) {
ev, ok := <-s.events
if ok {
return ev, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return nil, s.err
}
return nil, io.EOF
}
func (s *agentStream) Close() error {
<-s.done
return nil
}
func (s *agentStream) setErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.err = err
}
func sendStreamEvent(ctx context.Context, events chan<- *StreamEvent, ev *StreamEvent) bool {
select {
case events <- ev:
return true
case <-ctx.Done():
return false
}
}
func splitStreamTokens(reply string) []string {
if reply == "" {
return nil
}
parts := strings.Fields(reply)
if len(parts) == 0 {
return []string{reply}
}
out := make([]string, 0, len(parts))
for i, part := range parts {
if i > 0 {
part = " " + part
}
out = append(out, part)
}
return out
}
-175
View File
@@ -1,175 +0,0 @@
package agent
import (
"context"
"errors"
"io"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("StreamAsk must configure a tool handler")
}
calls++
result := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}})
return &ai.Response{
Reply: "planning",
Answer: "final answer",
ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}, Result: result.Content}},
}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("streamer"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
return input["text"].(string), nil
}))
stream, err := a.StreamAsk(context.Background(), "say hello")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
var types []StreamEventType
var tokens string
var done *Response
for {
event, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
types = append(types, event.Type)
if event.Type == StreamEventToken {
tokens += event.Token
}
if event.Type == StreamEventDone {
done = event.Response
}
}
want := []StreamEventType{StreamEventToolStart, StreamEventToolEnd, StreamEventToken, StreamEventToken, StreamEventToken, StreamEventDone}
if len(types) != len(want) {
t.Fatalf("event types = %v, want %v", types, want)
}
for i := range want {
if types[i] != want[i] {
t.Fatalf("event types = %v, want %v", types, want)
}
}
if tokens != "planning final answer" {
t.Fatalf("tokens = %q", tokens)
}
if done == nil || done.Reply != "planning\n\nfinal answer" {
t.Fatalf("done response = %#v", done)
}
if calls != 1 {
t.Fatalf("Generate calls = %d, want 1", calls)
}
}
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
if err == nil {
t.Fatal("StreamAsk helper should reject unsupported implementations")
}
}
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("stream disconnected after tool")
}
return &ai.Response{Reply: "finished from streamed checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-resume-agent"), WithCheckpoint(cp),
WithTool("charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
stream, err := a.StreamAsk(ctx, "charge order 42")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
break
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed StreamAsk = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resumed, err := ResumeStreamAsk(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("ResumeStreamAsk: %v", err)
}
var toolEvents int
var done *Response
for {
event, err := resumed.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("resumed Recv: %v", err)
}
if event.Type == StreamEventToolStart || event.Type == StreamEventToolEnd {
toolEvents++
}
if event.Type == StreamEventDone {
done = event.Response
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumeStreamAsk = %d, want completed tool was not replayed", toolRuns)
}
if toolEvents != 2 {
t.Fatalf("resumed tool events = %d, want start/end for replayed checkpoint result", toolEvents)
}
if done == nil || done.Reply != "finished from streamed checkpoint" || done.RunID != runs[0].ID {
t.Fatalf("done response = %#v", done)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
func (unsupportedAgent) Init(...Option) {}
func (unsupportedAgent) Options() Options { return Options{} }
func (unsupportedAgent) Ask(context.Context, string) (*Response, error) { return nil, nil }
func (unsupportedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
func (unsupportedAgent) Run() error { return nil }
func (unsupportedAgent) Stop() error { return nil }
func (unsupportedAgent) String() string { return "unsupported" }
-141
View File
@@ -1,141 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"go-micro.dev/v6/ai"
)
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
type textToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
// tool call as text JSON instead of a structured tool_calls field. It only runs
// calls whose names match the tools offered to the model, so ordinary JSON
// answers are left untouched.
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
calls := parseTextToolCalls(reply, tools)
if len(calls) == 0 {
return nil, "", false
}
handler := a.toolHandler()
results := make([]string, 0, len(calls))
for i := range calls {
result := handler(ctx, calls[i])
calls[i].Result = result.Content
if result.Refused != "" {
calls[i].Error = result.Refused
}
if result.Content != "" {
results = append(results, result.Content)
}
}
return calls, strings.Join(results, "\n"), true
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
allowed := map[string]bool{}
for _, tool := range tools {
allowed[tool.Name] = true
if tool.OriginalName != "" {
allowed[tool.OriginalName] = true
}
}
if len(allowed) == 0 {
return nil
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
}
}
return nil
}
func jsonCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
var out []string
if trimmed != "" {
out = append(out, trimmed)
}
for _, match := range fencedJSONBlock.FindAllStringSubmatch(text, -1) {
if len(match) > 1 {
out = append(out, strings.TrimSpace(match[1]))
}
}
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
out = append(out, strings.TrimSpace(text[start:end+1]))
}
return out
}
func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCall {
var root any
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
return nil
}
return collectTextToolCalls(root, allowed)
}
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
switch x := v.(type) {
case []any:
var out []ai.ToolCall
for _, item := range x {
out = append(out, collectTextToolCalls(item, allowed)...)
}
return out
case map[string]any:
if nested, ok := firstNestedToolCalls(x); ok {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = call.Arguments
}
if name == "" || !allowed[name] || input == nil {
return nil
}
id := call.ID
if id == "" {
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
}
return []ai.ToolCall{{ID: id, Name: name, Input: input}}
default:
return nil
}
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
if v, ok := m[key]; ok {
return v, true
}
}
return nil, false
}
func mapToTextToolCall(m map[string]any) textToolCall {
b, _ := json.Marshal(m)
var call textToolCall
_ = json.Unmarshal(b, &call)
return call
}
+1 -10
View File
@@ -27,7 +27,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -44,7 +43,6 @@ func init() {
ai.RegisterVideo("atlascloud", func(opts ...ai.Option) ai.VideoModel {
return NewProvider(opts...)
})
ai.RegisterStream("atlascloud")
}
// Provider implements the ai.Model interface for Atlas Cloud.
@@ -57,14 +55,7 @@ func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
// (e.g. to run CI conformance against a stronger tool-use model) without
// a code change; fall back to a sensible default otherwise.
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
options.Model = m
} else {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.atlascloud.ai"
+7 -53
View File
@@ -2,11 +2,7 @@ package atlascloud
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -85,58 +81,16 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream, sawIncludeUsage bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
if so, ok := body["stream_options"].(map[string]any); ok {
sawIncludeUsage, _ = so["include_usage"].(bool)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
if !sawIncludeUsage {
t.Fatal("stream request did not set stream_options.include_usage=true")
req := &ai.Request{
Prompt: "Hello",
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
usage, err := stream.Recv()
if err != nil {
t.Fatalf("usage chunk error: %v", err)
}
if usage.Usage.TotalTokens != 9 || usage.Usage.InputTokens != 7 || usage.Usage.OutputTokens != 2 {
t.Fatalf("usage = %#v; want input=7 output=2 total=9", usage.Usage)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
_, err := p.Stream(context.Background(), req)
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+7 -7
View File
@@ -34,7 +34,7 @@ func TestRegisteredProviders(t *testing.T) {
}
got = ai.RegisteredProviders("stream")
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
want = []string{"openai"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
@@ -44,12 +44,12 @@ func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
@@ -72,7 +72,7 @@ func TestCapabilityMatrix(t *testing.T) {
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true}) {
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
}
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
@@ -88,7 +88,7 @@ func TestRegisterStream(t *testing.T) {
}
got := ai.RegisteredProviders("stream")
want := []string{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
want := []string{"openai", "test-stream"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
+1 -3
View File
@@ -22,14 +22,12 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("groq", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
}
type Provider struct {
@@ -121,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: groq provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package groq
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
-117
View File
@@ -1,117 +0,0 @@
package openaiapi
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
// Stream opens an OpenAI-compatible chat completions SSE stream.
func Stream(ctx context.Context, opts ai.Options, req *ai.Request, basePath string) (ai.Stream, error) {
messages := []map[string]any{{"role": "system", "content": req.SystemPrompt}}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if opts.MaxTokens > 0 {
apiReq["max_tokens"] = opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(opts.BaseURL, "/") + basePath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Authorization", "Bearer "+opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &StreamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// StreamReader reads OpenAI-compatible server-sent event chunks.
type StreamReader struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *StreamReader) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *StreamReader) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
+1 -3
View File
@@ -22,14 +22,12 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("mistral", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("mistral")
}
type Provider struct {
@@ -121,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: mistral provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package mistral
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+10 -14
View File
@@ -93,10 +93,9 @@ func (c ToolCall) Scan(v any) error {
// ToolResult represents the result of a tool execution
type ToolResult struct {
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
// Refused names the reason a guardrail blocked the call before it ran
// ("max_steps", "loop", "approval"); empty when the call executed. A
// tool wrapper can switch on it to build reliability tooling — react to
@@ -122,16 +121,13 @@ const (
// tell which provider attempt produced the call and whether it is part of a
// retry budget. They are zero when no model-attempt context is known.
type RunInfo struct {
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
Trigger string // external trigger or schedule label that started the run, when known
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
}
type runInfoKey struct{}
-307
View File
@@ -1,307 +0,0 @@
package ai_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
func TestStreamProvidersConformToOpenAICompatibleSSE(t *testing.T) {
providers := conformingStreamProviders(t)
for _, provider := range providers {
provider := provider
t.Run(provider, func(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
if got := r.Header.Get("Accept"); got != "text/event-stream" {
t.Fatalf("Accept = %q, want text/event-stream", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("Authorization = %q, want bearer API key", got)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] == "" {
t.Fatal("request omitted model")
}
if body["stream"] != true {
t.Fatalf("stream = %#v, want true", body["stream"])
}
streamOptions, ok := body["stream_options"].(map[string]any)
if !ok || streamOptions["include_usage"] != true {
t.Fatalf("stream_options = %#v, want include_usage=true", body["stream_options"])
}
messages, ok := body["messages"].([]any)
if !ok || len(messages) != 4 {
t.Fatalf("messages = %#v, want system + history + prompt", body["messages"])
}
wantRoles := []string{"system", "user", "assistant", "user"}
for i, wantRole := range wantRoles {
message, ok := messages[i].(map[string]any)
if !ok || message["role"] != wantRole {
t.Fatalf("message[%d] = %#v, want role %q", i, messages[i], wantRole)
}
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(": keepalive\n\n"))
_, _ = w.Write([]byte("event: ignored\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
model := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
if model == nil {
t.Fatalf("ai.New(%q) returned nil", provider)
}
stream, err := model.Stream(context.Background(), &ai.Request{
SystemPrompt: "system",
Messages: []ai.Message{
{Role: "user", Content: "previous question"},
{Role: "assistant", Content: "previous answer"},
},
Prompt: "current question",
})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawRequest {
t.Fatal("server did not receive stream request")
}
assertStreamReply(t, stream, "hel")
assertStreamReply(t, stream, "lo")
usage, err := stream.Recv()
if err != nil {
t.Fatalf("usage chunk error: %v", err)
}
if usage.Reply != "" || usage.Usage != (ai.Usage{InputTokens: 3, OutputTokens: 2, TotalTokens: 5}) {
t.Fatalf("usage chunk = %#v", usage)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
})
}
}
func TestStreamProvidersCloseCancelsInFlightRequest(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
released := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
close(released)
}))
defer ts.Close()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
assertStreamReply(t, stream, "hel")
if err := stream.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if err := stream.Close(); err != nil {
t.Fatalf("second Close returned error: %v", err)
}
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("server did not observe canceled stream request")
}
})
}
}
func TestStreamProvidersPropagateProviderErrors(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream quota exhausted", http.StatusTooManyRequests)
}))
defer ts.Close()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for provider failure")
}
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "upstream quota exhausted") {
t.Fatalf("Stream error = %v, want provider status and body", err)
}
if strings.Contains(err.Error(), "test-key") {
t.Fatal("provider error leaked API key")
}
})
}
}
func TestStreamProvidersHonorCanceledContextBeforeRequest(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(ctx, &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for canceled context")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Stream error = %v, want context.Canceled", err)
}
if sawRequest {
t.Fatal("provider sent request after context was already canceled")
}
})
}
}
func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
for _, tc := range []struct {
provider string
keyEnv string
modelEnv string
}{
{provider: "openai", keyEnv: "OPENAI_API_KEY", modelEnv: "OPENAI_MODEL"},
{provider: "groq", keyEnv: "GROQ_API_KEY", modelEnv: "GROQ_MODEL"},
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
} {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
key := os.Getenv(tc.keyEnv)
if key == "" {
t.Skipf("%s not set; skipping configured provider stream check", tc.keyEnv)
}
opts := []ai.Option{ai.WithAPIKey(key)}
if model := os.Getenv(tc.modelEnv); model != "" {
opts = append(opts, ai.WithModel(model))
}
stream, err := ai.New(tc.provider, opts...).Stream(context.Background(), &ai.Request{Prompt: "Reply with exactly: ok"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
deadline := time.After(30 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for provider stream chunk")
default:
}
chunk, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
t.Fatal("provider stream ended without content")
}
t.Fatalf("Recv returned error: %v", err)
}
if chunk.Reply != "" {
return
}
}
})
}
}
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
for _, provider := range []string{"anthropic", "gemini"} {
provider := provider
t.Run(provider, func(t *testing.T) {
if caps := ai.ProviderCapabilities(provider); caps.Stream {
t.Fatalf("ProviderCapabilities(%q).Stream = true, want false", provider)
}
_, err := ai.New(provider, ai.WithAPIKey("test-key")).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if err != nil && strings.Contains(err.Error(), "test-key") {
t.Fatal("streaming unsupported error leaked API key")
}
})
}
}
func conformingStreamProviders(t *testing.T) []string {
t.Helper()
providers := ai.RegisteredProviders("stream")
allowed := map[string]struct{}{
"atlascloud": {},
"groq": {},
"mistral": {},
"openai": {},
"together": {},
}
var out []string
for _, provider := range providers {
if _, ok := allowed[provider]; ok {
out = append(out, provider)
}
}
want := []string{"atlascloud", "groq", "mistral", "openai", "together"}
if !reflect.DeepEqual(out, want) {
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
}
return out
}
func assertStreamReply(t *testing.T, stream ai.Stream, want string) {
t.Helper()
chunk, err := stream.Recv()
if err != nil {
t.Fatalf("Recv error = %v, want reply %q", err, want)
}
if chunk.Reply != want {
t.Fatalf("Reply = %q, want %q", chunk.Reply, want)
}
}
+1 -3
View File
@@ -22,14 +22,12 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("together", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("together")
}
type Provider struct {
@@ -121,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
return nil, fmt.Errorf("%w: together provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+3 -42
View File
@@ -2,11 +2,7 @@ package together
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -44,44 +40,9 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+35 -86
View File
@@ -43,9 +43,6 @@ func Deploy(c *cli.Context) error {
}
target, remotePath := resolveDeployTarget(c, target, cfg)
if c.Bool("dry-run") {
return printDeployPlan(c, target, cfg, remotePath)
}
return deploySSH(c, target, cfg, remotePath)
}
@@ -99,81 +96,6 @@ func showDeployTargets(cfg *config.Config) error {
return fmt.Errorf("%s", sb.String())
}
func printDeployPlan(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
if cfg == nil {
cfg, _ = config.Load(absDir)
}
if remotePath == "" {
remotePath = defaultRemotePath
}
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
}
fmt.Println()
fmt.Println(" \033[1mmicro deploy --dry-run\033[0m")
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n", target)
fmt.Printf(" Remote path %s\n", remotePath)
fmt.Printf(" Services %s\n", strings.Join(services, ", "))
fmt.Println()
fmt.Println(" Plan:")
fmt.Println(" 1. Build linux/amd64 service binaries")
fmt.Printf(" 2. Copy binaries to %s/bin/\n", remotePath)
fmt.Println(" 3. Enable and restart micro@<service> systemd units")
fmt.Println(" 4. Check service health")
fmt.Println()
fmt.Println(" No SSH, rsync, systemd, or remote deployment was performed.")
return nil
}
func deployServices(absDir string, cfg *config.Config, filterService string) ([]string, error) {
if filterService != "" && cfg != nil {
found := false
for _, svc := range cfg.Services {
if svc.Name == filterService {
found = true
break
}
}
if !found && len(cfg.Services) > 0 {
return nil, fmt.Errorf("service '%s' not found in configuration", filterService)
}
}
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return nil, err
}
services := make([]string, 0, len(sorted))
for _, svc := range sorted {
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
return services, nil
}
services := []string{filepath.Base(absDir)}
if filterService != "" && filterService != services[0] {
return nil, fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
return services, nil
}
func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
@@ -199,10 +121,19 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath str
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
// Early validation: resolve services before SSH checks.
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
// Early validation: Check if the requested service exists before SSH checks
filterService := c.String("service")
if filterService != "" && cfg != nil {
found := false
for _, svc := range cfg.Services {
if svc.Name == filterService {
found = true
break
}
}
if !found && len(cfg.Services) > 0 {
return fmt.Errorf("service '%s' not found in configuration", filterService)
}
}
// Step 1: Check SSH connectivity
@@ -222,6 +153,28 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath str
fmt.Println("\u2713")
// Step 3: Build binaries
var services []string
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
for _, svc := range sorted {
// If --service flag is provided, only include that service
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
} else {
// Single service project
services = []string{filepath.Base(absDir)}
// If --service flag was provided for a single-service project, validate it matches
if filterService != "" && filterService != services[0] {
return fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
}
fmt.Printf(" Building binaries... ")
if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil {
fmt.Println("\u2717")
@@ -540,10 +493,6 @@ The deploy process:
Name: "service",
Usage: "Deploy only a specific service (for multi-service projects)",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Print the deployment plan without building, connecting, copying, or restarting services",
},
},
})
}
-47
View File
@@ -17,7 +17,6 @@ func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
set.String("ssh", "", "")
set.String("service", "", "")
set.Bool("build", false, "")
set.Bool("dry-run", false, "")
if err := set.Parse(args); err != nil {
t.Fatalf("parse flags: %v", err)
}
@@ -119,49 +118,3 @@ deploy prod
t.Fatalf("deploy target = %#v", prod)
}
}
func TestDeployDryRunPlansConfiguredTargetWithoutRemoteSideEffects(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/micro.mu", []byte(`service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
oldwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldwd); err != nil {
t.Errorf("restore cwd: %v", err)
}
})
ctx := newDeployTestContext(t, "--dry-run", "prod")
if err := Deploy(ctx); err != nil {
t.Fatalf("dry-run deploy: %v", err)
}
}
func TestDeployDryRunValidatesRequestedService(t *testing.T) {
ctx := newDeployTestContext(t, "--dry-run", "--service", "missing", "prod")
cfg := &config.Config{Services: map[string]*config.Service{
"api": {Name: "api", Path: "./api"},
}}
err := printDeployPlan(ctx, "deploy@prod.example.com", cfg, defaultRemotePath)
if err == nil {
t.Fatal("expected dry-run to validate service names")
}
if !strings.Contains(err.Error(), "service 'missing' not found in configuration") {
t.Fatalf("unexpected error: %v", err)
}
}
+2 -5
View File
@@ -498,13 +498,10 @@ func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool,
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
// MCP tools are served on the gateway by default — every endpoint is an
// AI-callable tool, so surface it rather than hiding it behind a flag.
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", gw.Addr())
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
if mcpAddr != "" {
// Optional standalone MCP protocol server (e.g. for MCP clients).
fmt.Printf(" MCP Server \033[36mhttp://localhost%s\033[0m (full MCP protocol)\n", mcpAddr)
fmt.Printf(" MCP \033[36mhttp://localhost%s\033[0m\n", mcpAddr)
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", mcpAddr)
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
}
}
+1 -18
View File
@@ -19,7 +19,7 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
}
}
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy"} {
for _, want := range []string{"run", "chat", "flow", "inspect"} {
if !commands[want] {
t.Fatalf("missing %q command", want)
}
@@ -30,21 +30,4 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
if !subcommands["inspect"]["agent"] || !subcommands["inspect"]["flow"] {
t.Fatal("missing inspect boundary: inspect agent/flow")
}
var hasDeployDryRun bool
for _, command := range microcmd.DefaultCmd.App().Commands {
if command.Name != "deploy" {
continue
}
for _, flag := range command.Flags {
for _, name := range flag.Names() {
if name == "dry-run" {
hasDeployDryRun = true
}
}
}
}
if !hasDeployDryRun {
t.Fatal("missing deploy boundary: deploy --dry-run")
}
}
+1 -1
View File
@@ -324,4 +324,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
## Support
- GitHub Discussions: https://github.com/micro/go-micro/discussions
- Discord: https://discord.gg/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -1
View File
@@ -102,4 +102,4 @@ pytest tests/integration/ -v
## Questions?
- GitHub Discussions: https://github.com/micro/go-micro/discussions
- Discord: https://discord.gg/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -1
View File
@@ -370,4 +370,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
## Support
- GitHub Discussions: https://github.com/micro/go-micro/discussions
- Discord: https://discord.gg/G8Gk5j3uXr
- Discord: https://discord.gg/WeMU5AGxD
+1 -5
View File
@@ -19,11 +19,7 @@ func NewStream(opts ...Option) (Stream, error) {
for _, o := range opts {
o(&options)
}
st := options.Store
if st == nil {
st = store.NewMemoryStore()
}
return &mem{store: st}, nil
return &mem{store: store.NewMemoryStore()}, nil
}
type subscriber struct {
+2 -16
View File
@@ -1,25 +1,11 @@
package events
import (
"time"
import "time"
"go-micro.dev/v6/store"
)
type Options struct {
// Store persists published events for durability and replay. If nil, an
// in-memory store is used and events do not survive a restart.
Store store.Store
}
type Options struct{}
type Option func(o *Options)
// WithStore backs the stream with a durable store (e.g. the file store), so
// published events persist and can be replayed across restarts.
func WithStore(s store.Store) Option {
return func(o *Options) { o.Store = s }
}
type StoreOptions struct {
TTL time.Duration
Backup Backup
+4 -5
View File
@@ -80,11 +80,10 @@ A workflow as ordered, checkpointed steps that survives a crash and resumes wher
- **Checkpoint** — each step is persisted; on `Resume`, completed steps are not re-run (no duplicate side effects)
### [support](./support/)
A maintained 0-to-hero reference path in one runnable file:
- **scaffold** typed `customers`, `tickets`, and `notify` services
- **run/chat** with a support agent that uses those services as tools
- **inspect** the event-driven `intake` flow and approval gate
- **CI** keeps the deterministic mock-model journey runnable with `go test ./examples/support`
A real-world support desk — the "zero to hero" shape in one runnable file:
- **services** (`customers`, `tickets`, `notify`) become the agent's tools automatically
- **flow** turns a `ticket.created` event into work for the agent (the event is the prompt)
- **guardrail** — the agent triages freely but can't email a customer without passing the approval gate
## Coming Soon
-45
View File
@@ -1,45 +0,0 @@
# Durable agent run resume
This example shows the agent-side counterpart to `examples/flow-durable`: an
agent run is checkpointed with the same `Checkpoint` interface used by flows,
then resumed after an interruption without repeating a completed side effect.
The sample uses an in-memory store to keep repeated local runs deterministic;
use your service store for process-restart recovery.
Run it with:
```sh
go run ./examples/agent-durable
```
The demo model calls `inventory.reserve`, then fails to mimic a process dying
after the tool call was checkpointed. `micro.AgentPending` finds the unfinished
run and `micro.AgentResume` continues it from the saved checkpoint. The final
`tool executions: 1` line is the important bit: the reservation tool was not
called a second time during resume.
## When to use this instead of a durable flow
Use a durable flow when the path is known ahead of time: ordered service calls,
retries, timers, compensation, and a precise resume stage such as `reserve` or
`charge`. Use a checkpointed agent run when the path is open-ended and the model
may choose tools dynamically, but completed tool side effects still must not be
replayed after a crash or provider failure.
They compose: keep deterministic business process in `flow-durable`, then hand
off the judgment-heavy step to a checkpointed agent when the workflow needs
model-directed tool use. Both use the same `Checkpoint` backend, so inspection
and recovery can share one run-history store.
In a service, use the same pattern at startup:
```go
pending, _ := micro.AgentPending(ctx, agent)
for _, run := range pending {
_, _ = micro.AgentResume(ctx, agent, run.ID)
}
```
`context.Context` cancellation and deadlines are still honored by checkpoint
loads/saves, model calls, and tool calls. Runs with terminal statuses such as
`done`, `canceled`, and `expired` are not returned by `AgentPending`.
-88
View File
@@ -1,88 +0,0 @@
// Package main demonstrates durable agent runs: a checkpointed agent can
// resume after a crash without re-executing completed tool calls.
package main
import (
"context"
"errors"
"fmt"
"sync/atomic"
micro "go-micro.dev/v6"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
func main() {
ctx := context.Background()
checkpoint := micro.StoreCheckpoint(store.NewMemoryStore(), "durable-agent-demo")
model := &demoModel{failFirst: true}
ai.Register("durable-demo", func(opts ...ai.Option) ai.Model {
_ = model.Init(opts...)
return model
})
var reservations atomic.Int32
ag := micro.NewAgent("durable-agent-demo",
micro.AgentWithCheckpoint(checkpoint),
micro.AgentProvider("durable-demo"),
micro.AgentTool("inventory.reserve", "reserve inventory exactly once", map[string]any{
"sku": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
count := reservations.Add(1)
return fmt.Sprintf("reserved %s (execution %d)", input["sku"], count), nil
}),
)
_, err := ag.Ask(ctx, "reserve sku-123 and confirm")
fmt.Println("initial run:", err)
pending, err := micro.AgentPending(ctx, ag)
if err != nil {
panic(err)
}
if len(pending) == 0 {
panic("expected a checkpointed run to resume")
}
resp, err := micro.AgentResume(ctx, ag, pending[0].ID)
if err != nil {
panic(err)
}
fmt.Println("resumed reply:", resp.Reply)
fmt.Println("tool executions:", reservations.Load())
}
type demoModel struct {
failFirst bool
opts ai.Options
}
func (m *demoModel) Init(opts ...ai.Option) error {
m.opts = ai.NewOptions(opts...)
return nil
}
func (m *demoModel) Options() ai.Options { return m.opts }
func (m *demoModel) String() string { return "durable-demo" }
func (m *demoModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if m.opts.ToolHandler != nil {
res := m.opts.ToolHandler(ctx, ai.ToolCall{
ID: "reserve-1",
Name: "inventory.reserve",
Input: map[string]any{"sku": "sku-123"},
})
if res.Content == "" {
return nil, errors.New("reservation tool returned no content")
}
}
if m.failFirst {
m.failFirst = false
return nil, errors.New("simulated process interruption after checkpointed tool call")
}
return &ai.Response{Reply: "sku-123 is reserved; no duplicate reservation was made"}, nil
}
func (m *demoModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestDurableAgentExampleResumesWithoutReplayingTool(t *testing.T) {
out := captureStdout(t, main)
if !strings.Contains(out, "simulated process interruption after checkpointed tool call") {
t.Fatalf("example output %q did not show the initial interrupted run", out)
}
if !strings.Contains(out, "resumed reply: sku-123 is reserved; no duplicate reservation was made") {
t.Fatalf("example output %q did not show the resumed response", out)
}
if !strings.Contains(out, "tool executions: 1") {
t.Fatalf("example output %q did not prove the tool was not replayed", out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
os.Stdout = w
var buf bytes.Buffer
done := make(chan struct{})
go func() {
_, _ = io.Copy(&buf, r)
close(done)
}()
fn()
_ = w.Close()
os.Stdout = old
<-done
_ = r.Close()
return buf.String()
}
-8
View File
@@ -34,11 +34,3 @@ calls that tool, Go Micro persists the run with status `paused` and stage
`input-required`. Plain `agent.Resume` continues to support completed, failed,
and approval-paused runs; input-required runs are resumed with
`agent.ResumeInput` so the human response is explicit.
## Cancellation and deadlines
`ResumeInput` uses the caller's `context.Context` for checkpoint reads, writes,
and the resumed model/tool turn. If the context is canceled or its deadline
expires before the resume is committed, the call returns the context error and
the checkpointed run remains `paused` at `input-required`; list it with
`agent.Pending` and retry with a fresh context once the operator is ready.
+5 -27
View File
@@ -1,25 +1,9 @@
# Zero-to-hero support desk
# Support desk
A maintained 0-to-hero reference for the Go Micro lifecycle: scaffold a few
typed services, run them in one process, let an agent chat with those services
as tools, then inspect the durable flow that triggered the work. It is one
runnable file and one CI smoke test, so the reference path stays honest as the
framework evolves.
## The path
1. **Scaffold services**`customers`, `tickets`, and `notify` are ordinary
typed Go Micro services. Their request/response structs and method comments
become the tool contract the agent sees.
2. **Run the harness** — the example starts an in-memory registry, broker,
client, store, services, agent, and flow in one process; no external
dependencies or API key are required for the default run.
3. **Chat through an agent** — the `support` agent receives the ticket event as
a prompt and calls service tools to look up the customer, triage the ticket,
and draft a reply.
4. **Inspect the workflow** — the `intake` flow records the event-driven run and
prints the agent result, showing the service → agent → workflow lifecycle as
one runtime.
A real-world agent built the Go Micro way: a few services, an agent that
manages them, an event that triggers it, and a human-in-the-loop gate on the
one action that touches a customer. It's the "zero to hero" shape in one
runnable file.
## The scenario
@@ -60,12 +44,6 @@ agent, which:
go run main.go # mock model — deterministic, no API key
```
The maintained check is the same deterministic path:
```bash
go test ./examples/support
```
Against a live model, the agent reasons about the ticket itself instead of
following the script:
+1 -24
View File
@@ -1,32 +1,9 @@
package main
import (
"os"
"strings"
"testing"
)
import "testing"
func TestRunSupportMockSmoke(t *testing.T) {
if err := runSupport("mock"); err != nil {
t.Fatalf("support example failed: %v", err)
}
}
func TestZeroToHeroReadmeDocumentsLifecycle(t *testing.T) {
b, err := os.ReadFile("README.md")
if err != nil {
t.Fatalf("read README.md: %v", err)
}
doc := string(b)
for _, want := range []string{
"Scaffold services",
"Run the harness",
"Chat through an agent",
"Inspect the workflow",
"go test ./examples/support",
} {
if !strings.Contains(doc, want) {
t.Fatalf("README.md missing zero-to-hero step %q", want)
}
}
}
-212
View File
@@ -1,212 +0,0 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"go-micro.dev/v6/ai"
)
// AnalyzeOptions configures Analyze.
type AnalyzeOptions struct {
// MaxFeedbackSamples bounds the number of representative grader feedback
// strings retained per candidate. Values <= 0 use a small default.
MaxFeedbackSamples int
}
// AnalyzeOption configures Analyze.
type AnalyzeOption func(*AnalyzeOptions)
// AnalyzeMaxFeedbackSamples sets how many grader feedback examples are kept for
// each candidate in the report.
func AnalyzeMaxFeedbackSamples(n int) AnalyzeOption {
return func(o *AnalyzeOptions) { o.MaxFeedbackSamples = n }
}
// Report is the machine-readable output of Analyze. Candidates are ordered from
// worst to best so an agent, CLI, or human can pick the first improvement to try.
type Report struct {
Candidates []Candidate `json:"candidates"`
}
// Candidate identifies one underperforming flow step and the trace evidence that
// made it worth improving.
type Candidate struct {
Step string `json:"step"`
Metric string `json:"metric"`
Score float64 `json:"score"`
Runs int `json:"runs"`
Failures int `json:"failures"`
PassRate float64 `json:"pass_rate"`
ErrorRate float64 `json:"error_rate"`
AverageRetries float64 `json:"average_retries"`
P50Latency time.Duration `json:"p50_latency"`
P95Latency time.Duration `json:"p95_latency"`
SampleFeedback []string `json:"sample_feedback,omitempty"`
RunIDs []string `json:"run_ids,omitempty"`
}
// Analyze aggregates a bounded window of persisted flow runs and returns ranked
// hill-climbing candidates. It uses the same Run records read by Checkpoint.List:
// failed verification fields in step results drive pass-rate and feedback, step
// status drives error rate, and retry attempts contribute retry pressure. An
// empty window returns an empty report.
func Analyze(runs []Run, opts ...AnalyzeOption) Report {
o := AnalyzeOptions{MaxFeedbackSamples: 3}
for _, opt := range opts {
opt(&o)
}
if o.MaxFeedbackSamples <= 0 {
o.MaxFeedbackSamples = 3
}
stats := map[string]*stepStats{}
for _, run := range runs {
for _, step := range run.Steps {
if step.Name == "" {
continue
}
s := stats[step.Name]
if s == nil {
s = &stepStats{}
stats[step.Name] = s
}
s.runs++
s.runIDs = appendUnique(s.runIDs, run.ID)
if step.Attempts > 1 {
s.retries += step.Attempts - 1
}
if step.Status == "failed" || step.Error != "" {
s.errors++
}
if len(run.Steps) > 0 && !run.Started.IsZero() && !run.Updated.IsZero() {
s.latencies = append(s.latencies, run.Updated.Sub(run.Started)/time.Duration(len(run.Steps)))
}
passed, feedback, ok := verificationFields(step.Result)
if ok {
s.graded++
if !passed {
s.gradeFailures++
if feedback != "" && len(s.feedback) < o.MaxFeedbackSamples {
s.feedback = append(s.feedback, feedback)
}
}
}
}
}
report := Report{}
for step, s := range stats {
if s.runs == 0 {
continue
}
failures := s.errors + s.gradeFailures
passRate := 1.0
if s.graded > 0 {
passRate = float64(s.graded-s.gradeFailures) / float64(s.graded)
} else if s.errors > 0 {
passRate = float64(s.runs-s.errors) / float64(s.runs)
}
errorRate := float64(s.errors) / float64(s.runs)
avgRetries := float64(s.retries) / float64(s.runs)
score := float64(s.gradeFailures)*3 + float64(s.errors)*2 + avgRetries
metric := "pass_rate"
if s.gradeFailures == 0 && s.errors > 0 {
metric = "error_rate"
} else if s.gradeFailures == 0 && s.errors == 0 && s.retries > 0 {
metric = "retry_count"
}
report.Candidates = append(report.Candidates, Candidate{
Step: step, Metric: metric, Score: score, Runs: s.runs, Failures: failures,
PassRate: passRate, ErrorRate: errorRate, AverageRetries: avgRetries,
P50Latency: percentile(s.latencies, 0.50), P95Latency: percentile(s.latencies, 0.95),
SampleFeedback: append([]string(nil), s.feedback...), RunIDs: append([]string(nil), s.runIDs...),
})
}
sort.SliceStable(report.Candidates, func(i, j int) bool {
a, b := report.Candidates[i], report.Candidates[j]
if a.Score == b.Score {
return a.Step < b.Step
}
return a.Score > b.Score
})
return report
}
type stepStats struct {
runs, graded, gradeFailures, errors, retries int
feedback, runIDs []string
latencies []time.Duration
}
// PromptOptimizer proposes prompt improvements for a candidate without mutating
// the source flow. Applying the returned prompt stays explicitly gated by the caller.
type PromptOptimizer struct{ model ai.Model }
// LLMOptimizer returns an optimizer that asks model to revise prompts for
// Analyze candidates. The model is injected so tests and callers can use mocks.
func LLMOptimizer(model ai.Model) *PromptOptimizer { return &PromptOptimizer{model: model} }
// OptimizePrompt asks the model for a revised prompt for candidate using the
// current prompt and trace feedback. It returns only the proposal; it never
// modifies a Flow, Step, or Checkpoint.
func (o *PromptOptimizer) OptimizePrompt(ctx context.Context, candidate Candidate, currentPrompt string) (string, error) {
if o == nil || o.model == nil {
return "", fmt.Errorf("flow: LLMOptimizer requires a model")
}
prompt := fmt.Sprintf("Revise this workflow step prompt to improve the failing step.\nStep: %s\nMetric: %s\nScore: %.2f\nFeedback:\n- %s\n\nCurrent prompt:\n%s\n\nReturn only the revised prompt.", candidate.Step, candidate.Metric, candidate.Score, strings.Join(candidate.SampleFeedback, "\n- "), currentPrompt)
resp, err := o.model.Generate(ctx, &ai.Request{Prompt: prompt})
if err != nil {
return "", err
}
proposal := strings.TrimSpace(resp.Answer)
if proposal == "" {
proposal = strings.TrimSpace(resp.Reply)
}
if proposal == "" {
return "", fmt.Errorf("flow: LLMOptimizer returned an empty prompt")
}
return proposal, nil
}
func verificationFields(result string) (bool, string, bool) {
if result == "" {
return false, "", false
}
var obj map[string]any
if err := json.Unmarshal([]byte(result), &obj); err != nil {
return false, "", false
}
v, ok := obj["verification_passed"].(bool)
if !ok {
return false, "", false
}
fb, _ := obj["verification_feedback"].(string)
return v, fb, true
}
func appendUnique(values []string, value string) []string {
if value == "" {
return values
}
for _, v := range values {
if v == value {
return values
}
}
return append(values, value)
}
func percentile(values []time.Duration, p float64) time.Duration {
if len(values) == 0 {
return 0
}
sorted := append([]time.Duration(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)-1) * p)
return sorted[idx]
}
-89
View File
@@ -1,89 +0,0 @@
package flow
import (
"context"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
)
func TestAnalyzeRanksFailedGraderStepAbovePassingStep(t *testing.T) {
now := time.Now()
runs := []Run{
{ID: "run-1", Started: now, Updated: now.Add(time.Second), Steps: []StepRecord{
{Name: "draft", Status: "done", Attempts: 2, Result: `{"verification_passed":false,"verification_feedback":"cite sources"}`},
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
}},
{ID: "run-2", Started: now, Updated: now.Add(2 * time.Second), Steps: []StepRecord{
{Name: "draft", Status: "done", Attempts: 1, Result: `{"verification_passed":false,"verification_feedback":"too vague"}`},
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
}},
}
report := Analyze(runs)
if len(report.Candidates) != 2 {
t.Fatalf("Analyze returned %d candidates, want 2", len(report.Candidates))
}
if got := report.Candidates[0].Step; got != "draft" {
t.Fatalf("top candidate = %q, want draft", got)
}
if report.Candidates[0].PassRate != 0 {
t.Fatalf("draft pass rate = %v, want 0", report.Candidates[0].PassRate)
}
}
func TestAnalyzeCarriesFeedbackSamplesAndRunIDs(t *testing.T) {
report := Analyze([]Run{{ID: "run-9", Steps: []StepRecord{{
Name: "grade", Status: "done", Attempts: 3,
Result: `{"verification_passed":false,"verification_feedback":"include totals"}`,
}}}})
if len(report.Candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(report.Candidates))
}
c := report.Candidates[0]
if len(c.SampleFeedback) != 1 || c.SampleFeedback[0] != "include totals" {
t.Fatalf("feedback = %#v, want include totals", c.SampleFeedback)
}
if len(c.RunIDs) != 1 || c.RunIDs[0] != "run-9" {
t.Fatalf("run ids = %#v, want run-9", c.RunIDs)
}
if c.AverageRetries != 2 {
t.Fatalf("average retries = %v, want 2", c.AverageRetries)
}
}
func TestAnalyzeEmptyWindowReturnsEmptyReport(t *testing.T) {
if got := Analyze(nil); len(got.Candidates) != 0 {
t.Fatalf("empty Analyze candidates = %d, want 0", len(got.Candidates))
}
}
func TestLLMOptimizerReturnsProposalWithoutMutatingFlow(t *testing.T) {
f := New("optimize", Prompt("original prompt"))
before := f.opts.Prompt
optimizer := LLMOptimizer(&optimizerModel{reply: "revised prompt"})
proposal, err := optimizer.OptimizePrompt(context.Background(), Candidate{Step: "draft", Metric: "pass_rate", SampleFeedback: []string{"cite sources"}}, before)
if err != nil {
t.Fatalf("OptimizePrompt returned error: %v", err)
}
if !strings.Contains(proposal, "revised") {
t.Fatalf("proposal = %q, want revised prompt", proposal)
}
if f.opts.Prompt != before {
t.Fatalf("flow prompt mutated to %q, want %q", f.opts.Prompt, before)
}
}
type optimizerModel struct{ reply string }
func (m *optimizerModel) Init(...ai.Option) error { return nil }
func (m *optimizerModel) Options() ai.Options { return ai.Options{} }
func (m *optimizerModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return &ai.Response{Reply: m.reply}, nil
}
func (m *optimizerModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *optimizerModel) String() string { return "optimizer" }
-63
View File
@@ -7,7 +7,6 @@ import (
"go-micro.dev/v6/client"
codecbytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/store"
)
// fakeClient embeds the default client (so NewRequest works) and
@@ -66,65 +65,3 @@ func TestExecuteDispatchesToAgent(t *testing.T) {
t.Errorf("rendered prompt = %q, want %q", results[0].Prompt, "welcome bob")
}
}
// A caller-owned schedule can trigger an agent workflow without a human chat
// prompt and still leave the normal flow run metadata behind for inspection.
func TestScheduledAgentRunHarnessContract(t *testing.T) {
ctx := context.Background()
cp := StoreCheckpoint(store.NewMemoryStore(), "scheduled-contract")
f := New("scheduled-contract",
Trigger("schedule.daily"),
WithCheckpoint(cp),
Steps(Step{Name: "summarize", Run: Dispatch("ops-agent")}),
)
var parentID string
f.client = &fakeClient{
Client: client.DefaultClient,
callFn: func(req client.Request, rsp interface{}) error {
if req.Service() != "ops-agent" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("dispatched to %s.%s, want ops-agent.Agent.Chat", req.Service(), req.Endpoint())
}
reqFrame := req.Body().(*codecbytes.Frame)
var body map[string]string
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
t.Fatalf("request body: %v", err)
}
parentID = body["parent_id"]
if body["message"] != "run unattended daily ops review" {
t.Fatalf("message = %q, want scheduled payload", body["message"])
}
frame := rsp.(*codecbytes.Frame)
frame.Data = []byte(`{"reply":"review queued","agent":"ops-agent","parent_id":"` + parentID + `"}`)
return nil
},
}
if err := Scheduled(f, "run unattended daily ops review").Tick(ctx); err != nil {
t.Fatalf("scheduled tick: %v", err)
}
if parentID == "" {
t.Fatal("dispatch did not receive the scheduled flow run id as parent_id")
}
runs, err := cp.List(ctx)
if err != nil {
t.Fatalf("list scheduled runs: %v", err)
}
if len(runs) != 1 {
t.Fatalf("got %d runs, want 1", len(runs))
}
run := runs[0]
if run.ID != parentID {
t.Fatalf("run ID = %q, parent_id = %q", run.ID, parentID)
}
if run.Flow != "scheduled-contract" || run.Status != "done" {
t.Fatalf("run = %+v, want scheduled-contract done", run)
}
if got := run.State.String(); got != "review queued" {
t.Fatalf("run result = %q, want agent reply", got)
}
if len(run.Steps) != 1 || run.Steps[0].Name != "summarize" || run.Steps[0].Status != "done" {
t.Fatalf("steps = %+v, want summarize done", run.Steps)
}
}
-45
View File
@@ -1,45 +0,0 @@
package flow_test
import (
"context"
"fmt"
"strings"
"go-micro.dev/v6/flow"
)
func ExampleVerify() {
generate := func(_ context.Context, in flow.State) (flow.State, error) {
if strings.Contains(in.String(), "feedback") {
in.Data = []byte(`{"answer":"include a source"}`)
return in, nil
}
in.Data = []byte(`{"answer":"draft"}`)
return in, nil
}
grader := func(_ context.Context, out flow.State) (bool, string, error) {
return strings.Contains(out.String(), "source"), "add a source", nil
}
out, _ := flow.Verify(generate, grader, flow.VerifyMaxAttempts(2))(context.Background(), flow.State{})
fmt.Println(strings.Contains(out.String(), `"verification_passed":true`))
// Output: true
}
func ExampleAnalyze() {
runs := []flow.Run{{
ID: "run-1",
Steps: []flow.StepRecord{{
Name: "draft",
Status: "done",
Result: `{"verification_passed":false,"verification_feedback":"add a source"}`,
}},
}}
report := flow.Analyze(runs)
fmt.Println(report.Candidates[0].Step)
fmt.Println(report.Candidates[0].SampleFeedback[0])
// Output:
// draft
// add a source
}
+2 -10
View File
@@ -76,7 +76,6 @@ type Result struct {
Answer string `json:"answer,omitempty"`
ToolCalls []string `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration_seconds"`
}
@@ -142,8 +141,7 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
if f.opts.TriggerTopic != "" {
sub, err := br.Subscribe(f.opts.TriggerTopic, func(p broker.Event) error {
data := string(p.Message().Body)
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
if err := f.Execute(ctx, data); err != nil {
if err := f.Execute(context.Background(), data); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
}
return nil
@@ -224,10 +222,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
}
runID := uuid.New().String()
info, _ := ai.RunInfoFrom(ctx)
info.RunID = runID
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
start := time.Now()
@@ -251,7 +246,6 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
result.Duration = time.Since(start).Seconds()
if err != nil {
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return err
}
@@ -267,7 +261,6 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
if err != nil {
result.Duration = time.Since(start).Seconds()
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return fmt.Errorf("discover tools: %w", err)
}
@@ -281,7 +274,6 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
if err != nil {
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return err
}
+14 -45
View File
@@ -16,18 +16,13 @@ const (
spanNameFlowRun = "flow.run"
spanNameFlowStep = "flow.step"
AttrFlowRunID = "flow.run.id"
AttrFlowParentID = "flow.run.parent_id"
AttrFlowName = "flow.name"
AttrFlowStepName = "flow.step.name"
AttrFlowStatus = "flow.status"
AttrFlowAttempts = "flow.step.attempts"
AttrFlowLatencyMS = "flow.latency_ms"
AttrFlowErrorKind = "flow.error.kind"
AttrFlowVerificationStatus = "flow.verification.status"
AttrFlowVerificationNote = "flow.verification.note"
AttrFlowDispatch = "flow.dispatch"
AttrFlowTrigger = "flow.trigger"
AttrFlowRunID = "flow.run.id"
AttrFlowParentID = "flow.run.parent_id"
AttrFlowName = "flow.name"
AttrFlowStepName = "flow.step.name"
AttrFlowStatus = "flow.status"
AttrFlowAttempts = "flow.step.attempts"
AttrFlowLatencyMS = "flow.latency_ms"
)
func (f *Flow) tracer() trace.Tracer {
@@ -38,15 +33,12 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
if f.opts.TraceProvider == nil {
return ctx, func(Run, error) {}
}
info, _ := ai.RunInfoFrom(ctx)
attrs := []attribute.KeyValue{
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
attribute.String(AttrFlowRunID, run.ID),
attribute.String(AttrFlowParentID, run.ParentID),
attribute.String(AttrFlowName, f.name),
attribute.String(AttrFlowStatus, run.Status),
}
attrs = appendRunInfoDispatch(attrs, info)
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
))
start := time.Now()
return ctx, func(done Run, err error) {
span.SetAttributes(
@@ -55,7 +47,6 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
)
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
@@ -64,51 +55,29 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
}
}
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, Verification, error) {
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, error) {
if f.opts.TraceProvider == nil {
return f.runStep(ctx, step, in)
}
info, _ := ai.RunInfoFrom(ctx)
attrs := []attribute.KeyValue{
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
attribute.String(AttrFlowRunID, info.RunID),
attribute.String(AttrFlowParentID, info.ParentID),
attribute.String(AttrFlowName, f.name),
attribute.String(AttrFlowStepName, step.Name),
}
attrs = appendRunInfoDispatch(attrs, info)
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(attrs...))
))
start := time.Now()
out, attempts, verification, err := f.runStep(ctx, step, in)
out, attempts, err := f.runStep(ctx, step, in)
span.SetAttributes(
attribute.Int(AttrFlowAttempts, attempts),
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
)
if verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "passed"))
}
if verification.Feedback != "" {
span.SetAttributes(attribute.String(AttrFlowVerificationNote, verification.Feedback))
if !verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
}
}
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
return out, attempts, verification, err
}
func appendRunInfoDispatch(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrFlowDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrFlowTrigger, info.Trigger))
}
return attrs
return out, attempts, err
}
-26
View File
@@ -74,29 +74,3 @@ func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
func withTestRunInfo(ctx context.Context, runID string) context.Context {
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
}
func TestScheduledFlowOpenTelemetryDispatchAttributes(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
step := Step{Name: "summarize", Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("queued")
return in, nil
}}
f := New("scheduled-observed", Trigger("schedule.daily"), WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "scheduled-observed")), TraceProvider(tp), Steps(step))
if err := Scheduled(f, "daily ops review").Tick(context.Background()); err != nil {
t.Fatal(err)
}
for _, span := range exp.GetSpans().Snapshots() {
if span.Name() != spanNameFlowRun {
continue
}
attrs := flowSpanAttributes(span.Attributes())
if attrs[AttrFlowDispatch] != "schedule" || attrs[AttrFlowTrigger] != "schedule.daily" {
t.Fatalf("scheduled run span dispatch attributes = %#v", attrs)
}
return
}
t.Fatal("flow run span not emitted")
}
-60
View File
@@ -1,60 +0,0 @@
package flow
import (
"context"
"time"
"go-micro.dev/v6/ai"
)
// Schedule binds a flow to a recurring work item without introducing a
// scheduler service. It is a small harness contract: callers own the clock,
// Go Micro owns turning each tick into the same inspectable flow run used for
// broker events and direct Execute calls.
type Schedule struct {
flow *Flow
data string
}
// Scheduled returns a deterministic scheduled-run harness for this flow.
// Tests and event loops can call Tick directly; production processes can wire
// the same contract to time.Ticker through RunEvery. Each tick calls Execute, so
// checkpointed run history, parent/run metadata, cancellation, and inspection
// stay on the normal flow surfaces.
func Scheduled(f *Flow, data string) Schedule {
return Schedule{flow: f, data: data}
}
// Tick starts one scheduled run immediately and returns when that run finishes.
func (s Schedule) Tick(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
info, _ := ai.RunInfoFrom(ctx)
info.Dispatch = "schedule"
if info.Trigger == "" {
info.Trigger = s.flow.opts.TriggerTopic
}
if info.Trigger == "" {
info.Trigger = "schedule"
}
return s.flow.Execute(ai.WithRunInfo(ctx, info), s.data)
}
// RunEvery drives scheduled runs from a ticker until ctx is canceled. It does
// not persist schedule definitions or host a scheduler; it only adapts a caller
// owned cadence to Tick.
func (s Schedule) RunEvery(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := s.Tick(ctx); err != nil {
return err
}
}
}
}
+21 -85
View File
@@ -52,53 +52,22 @@ func (s State) String() string { return string(s.Data) }
// returns the next state.
type StepFunc func(ctx context.Context, in State) (State, error)
// Verifier grades a step output before the flow advances. Returning
// Passed=false converts the grade into a retryable VerificationError, so
// the existing step retry/supervision path can feed Feedback into the next
// attempt through ai.RunInfo.VerificationFeedback.
type Verifier func(ctx context.Context, out State) (Verification, error)
// Verification is the verifier's deterministic grade for one step attempt.
type Verification struct {
Passed bool
Feedback string
}
// VerificationError reports a failed grade. It is returned from runStep so
// existing retry, checkpoint, and trace paths handle verifier failures the
// same way they handle step execution failures.
type VerificationError struct {
Step string
Feedback string
}
func (e *VerificationError) Error() string {
if e.Feedback == "" {
return fmt.Sprintf("flow: verification failed for step %q", e.Step)
}
return fmt.Sprintf("flow: verification failed for step %q: %s", e.Step, e.Feedback)
}
// Step is one unit of a flow — a named action with optional retry and
// verification hooks. There is one Step kind; the action is the Run func,
// and the Call/LLM/Agent helpers produce the common ones.
// Step is one unit of a flow — a named action with an optional retry
// override. There is one Step kind; the action is the Run func, and the
// Call/LLM/Agent helpers produce the common ones.
type Step struct {
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
Verify Verifier // optional grade; failed grades retry the step with feedback in RunInfo
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
}
// StepRecord is the recorded outcome of one step within a run.
type StepRecord struct {
Name string `json:"name"`
Status string `json:"status"` // pending | in_progress | done | failed
Attempts int `json:"attempts"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
VerificationStatus string `json:"verification_status,omitempty"` // passed | failed
VerificationNote string `json:"verification_note,omitempty"`
Name string `json:"name"`
Status string `json:"status"` // pending | in_progress | done | failed
Attempts int `json:"attempts"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// Run is the persisted record of one flow execution — what a Checkpoint
@@ -433,12 +402,7 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
steps := f.opts.Steps
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
info, _ := ai.RunInfoFrom(ctx)
info.RunID = run.ID
info.ParentID = run.ParentID
info.Agent = f.name
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
ctx, finishSpan := f.startRunSpan(ctx, run)
var spanErr error
defer func() { finishSpan(run, spanErr) }()
@@ -461,14 +425,12 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
return run, err
}
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
out, attempts, err := f.runStepSpan(ctx, step, run.State)
run.Steps[i].Attempts = attempts
applyVerificationRecord(&run.Steps[i], verification)
if err != nil {
spanErr = err
run.Steps[i].Status = "failed"
run.Steps[i].Error = err.Error()
run.Steps[i].ErrorKind = string(ai.ClassifyError(err))
run.Status = "failed"
if saveErr := f.save(ctx, run); saveErr != nil {
spanErr = saveErr
@@ -512,65 +474,40 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
// runStep runs one step, retrying on error up to the resolved retry count.
// A step with no Run function is a configuration error, and a canceled run
// stops retrying immediately rather than burning the rest of its budget.
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Verification, error) {
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, error) {
if step.Run == nil {
return in, 0, Verification{}, fmt.Errorf("flow: step %q has no Run function", step.Name)
return in, 0, fmt.Errorf("flow: step %q has no Run function", step.Name)
}
retries := f.opts.Retry
if step.Retry > 0 {
retries = step.Retry
}
var lastErr error
var lastVerification Verification
var feedback string
for attempt := 1; attempt <= retries+1; attempt++ {
// Stop the moment the run's context is canceled or its deadline
// passes — a canceled run shouldn't keep retrying, and the context
// error is surfaced so callers can detect cancellation upstream.
if err := ctx.Err(); err != nil {
return in, attempt - 1, lastVerification, err
return in, attempt - 1, err
}
attemptCtx := ctx
if info, ok := ai.RunInfoFrom(ctx); ok {
info.Step = step.Name
info.VerificationFeedback = feedback
attemptCtx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(attemptCtx, in)
if err == nil && step.Verify != nil {
lastVerification, err = step.Verify(attemptCtx, out)
if err == nil && !lastVerification.Passed {
err = &VerificationError{Step: step.Name, Feedback: lastVerification.Feedback}
}
ctx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(ctx, in)
if err == nil {
return out, attempt, lastVerification, nil
return out, attempt, nil
}
lastErr = err
if verr, ok := err.(*VerificationError); ok {
feedback = verr.Feedback
}
if attempt <= retries && f.opts.RetryBackoff > 0 {
select {
case <-time.After(f.opts.RetryBackoff):
case <-ctx.Done():
return in, attempt, lastVerification, ctx.Err()
return in, attempt, ctx.Err()
}
}
}
return in, retries + 1, lastVerification, lastErr
}
func applyVerificationRecord(record *StepRecord, verification Verification) {
if verification.Passed {
record.VerificationStatus = "passed"
}
if verification.Feedback != "" {
record.VerificationNote = truncate(verification.Feedback, 200)
if !verification.Passed {
record.VerificationStatus = "failed"
}
}
return in, retries + 1, lastErr
}
func (f *Flow) save(ctx context.Context, run Run) error {
@@ -618,7 +555,6 @@ func resultFromRun(trigger string, run Run) Result {
r.ToolCalls = append(r.ToolCalls, s.Name+":"+s.Status)
if s.Error != "" {
r.Error = s.Error
r.ErrorKind = s.ErrorKind
}
}
if run.Status == "done" {
-34
View File
@@ -548,37 +548,3 @@ func TestStateSetScan(t *testing.T) {
t.Errorf("round-trip failed: %+v", got)
}
}
func TestFlowFailureRecordsErrorKind(t *testing.T) {
cp := StoreCheckpoint(store.NewMemoryStore(), "failure-kind")
f := New("failure-kind",
WithCheckpoint(cp),
Steps(Step{Name: "limited", Run: func(_ context.Context, in State) (State, error) {
return in, errors.New("rate limit exceeded")
}}),
)
err := f.Execute(context.Background(), "payload")
if err == nil {
t.Fatal("Execute error = nil, want failure")
}
runs, listErr := cp.List(context.Background())
if listErr != nil {
t.Fatalf("List: %v", listErr)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
if got := runs[0].Steps[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
t.Fatalf("step error kind = %q, want %q", got, ai.ErrorKindRateLimited)
}
results := f.Results()
if len(results) != 1 {
t.Fatalf("results = %d, want 1", len(results))
}
if got := results[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
t.Fatalf("result error kind = %q, want %q", got, ai.ErrorKindRateLimited)
}
}
-100
View File
@@ -1,100 +0,0 @@
package flow
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
func TestFlowStepVerificationRetriesWithFeedback(t *testing.T) {
var attempts int
var feedback []string
step := Step{
Name: "draft",
Retry: 1,
Run: func(ctx context.Context, in State) (State, error) {
attempts++
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from verified step")
}
feedback = append(feedback, info.VerificationFeedback)
if info.VerificationFeedback == "add evidence" {
in.Data = []byte("answer with evidence")
} else {
in.Data = []byte("answer")
}
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
if out.String() == "answer with evidence" {
return Verification{Passed: true, Feedback: "meets rubric"}, nil
}
return Verification{Feedback: "add evidence"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified")
f := New("verified", WithCheckpoint(cp), Steps(step))
if err := f.Execute(context.Background(), "question"); err != nil {
t.Fatal(err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
if len(feedback) != 2 || feedback[0] != "" || feedback[1] != "add evidence" {
t.Fatalf("feedback = %#v, want empty then verifier feedback", feedback)
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if stepRecord.Status != "done" || stepRecord.Attempts != 2 || stepRecord.VerificationStatus != "passed" || stepRecord.VerificationNote != "meets rubric" {
t.Fatalf("step record = %#v", stepRecord)
}
}
func TestFlowStepVerificationFailureIsCheckpointed(t *testing.T) {
step := Step{
Name: "grade",
Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("bad")
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
return Verification{Feedback: "missing citation"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified-fail")
f := New("verified-fail", WithCheckpoint(cp), Steps(step))
err := f.Execute(context.Background(), "question")
if err == nil {
t.Fatal("Execute succeeded, want verification failure")
}
var verr *VerificationError
if !errors.As(err, &verr) {
t.Fatalf("error = %T %v, want VerificationError", err, err)
}
if verr.Feedback != "missing citation" {
t.Fatalf("feedback = %q, want missing citation", verr.Feedback)
}
runs, listErr := cp.List(context.Background())
if listErr != nil {
t.Fatal(listErr)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if runs[0].Status != "failed" || stepRecord.VerificationStatus != "failed" || stepRecord.VerificationNote != "missing citation" {
t.Fatalf("run = %#v step = %#v", runs[0], stepRecord)
}
}
-191
View File
@@ -1,191 +0,0 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
)
// Grader checks a step output against a rubric. It returns pass=true when the
// output is acceptable; otherwise feedback should explain what the next attempt
// should fix.
type Grader func(ctx context.Context, out State) (pass bool, feedback string, err error)
// VerifyOptions configure Verify.
type VerifyOptions struct {
// MaxAttempts bounds how many times the body can run. Default 2.
MaxAttempts int
// Backoff waits between failed grades. Zero means retry immediately.
Backoff time.Duration
// FeedbackField is the JSON field used to thread grader feedback into the
// next attempt's input. Default "feedback".
FeedbackField string
}
// VerifyOption configures Verify.
type VerifyOption func(*VerifyOptions)
// VerifyMaxAttempts sets the total attempt budget for Verify. Values <= 0 use
// the default of 2.
func VerifyMaxAttempts(n int) VerifyOption { return func(o *VerifyOptions) { o.MaxAttempts = n } }
// VerifyBackoff sets the delay between failed verification attempts.
func VerifyBackoff(d time.Duration) VerifyOption { return func(o *VerifyOptions) { o.Backoff = d } }
// VerifyFeedbackField sets the JSON field used to pass grader feedback to the
// next body attempt. Empty values use "feedback".
func VerifyFeedbackField(field string) VerifyOption {
return func(o *VerifyOptions) { o.FeedbackField = field }
}
// Verify runs body, grades its output, and retries with grader feedback threaded
// into the next input until the grader passes or MaxAttempts is exhausted. It is
// a StepFunc, so it composes directly as Step.Run with Loop, LLM, Call, Agent, or
// any code-defined step.
//
// On a failed grade, Verify adds the feedback to the next attempt's input as a
// JSON field named "feedback" (or VerifyFeedbackField). When all attempts fail,
// it returns the last output without error, annotated with verification fields so
// the run can keep the bounded failure outcome in its state:
// "verification_passed": false, "verification_feedback", and
// "verification_attempts".
func Verify(body StepFunc, grader Grader, opts ...VerifyOption) StepFunc {
o := VerifyOptions{MaxAttempts: 2, FeedbackField: "feedback"}
for _, op := range opts {
op(&o)
}
if o.MaxAttempts <= 0 {
o.MaxAttempts = 2
}
if o.FeedbackField == "" {
o.FeedbackField = "feedback"
}
return func(ctx context.Context, in State) (State, error) {
if body == nil {
return in, fmt.Errorf("flow: Verify requires a body step")
}
if grader == nil {
return in, fmt.Errorf("flow: Verify requires a grader")
}
cur := in
last := in
feedback := ""
for attempt := 1; attempt <= o.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return last, err
}
if feedback != "" {
var err error
cur, err = stateWithField(cur, o.FeedbackField, feedback)
if err != nil {
return last, err
}
}
out, err := body(ctx, cur)
if err != nil {
return last, fmt.Errorf("verify attempt %d: %w", attempt, err)
}
last = out
pass, fb, err := grader(ctx, out)
if err != nil {
return last, fmt.Errorf("verify grade attempt %d: %w", attempt, err)
}
if pass {
return stateWithVerification(out, true, fb, attempt)
}
feedback = fb
cur = in
if attempt < o.MaxAttempts && o.Backoff > 0 {
select {
case <-time.After(o.Backoff):
case <-ctx.Done():
return last, ctx.Err()
}
}
}
return stateWithVerification(last, false, feedback, o.MaxAttempts)
}
}
// LLMGrader returns a grader that asks the flow model to judge the latest output
// against rubric. The model should answer with pass/fail plus short feedback.
// It reuses the flow's configured model, so it must run inside a flow.
func LLMGrader(rubric string) Grader {
return func(ctx context.Context, out State) (bool, string, error) {
d := depsFrom(ctx)
if d == nil || d.model == nil {
return false, "", fmt.Errorf("flow: LLMGrader requires a flow model (set Provider/APIKey)")
}
prompt := fmt.Sprintf("Grade the latest result against this rubric:\n%s\n\nLatest result:\n%s\n\nAnswer with PASS or FAIL on the first line, followed by one short feedback sentence.", rubric, out.String())
resp, err := d.model.Generate(ctx, &ai.Request{Prompt: prompt})
if err != nil {
return false, "", err
}
reply := resp.Answer
if reply == "" {
reply = resp.Reply
}
return parseGrade(reply)
}
}
func parseGrade(reply string) (bool, string, error) {
text := strings.TrimSpace(reply)
if text == "" {
return false, "", fmt.Errorf("flow: LLMGrader returned an empty grade")
}
lines := strings.SplitN(text, "\n", 2)
first := strings.ToLower(strings.TrimSpace(lines[0]))
feedback := ""
if len(lines) > 1 {
feedback = strings.TrimSpace(lines[1])
}
pass := strings.HasPrefix(first, "pass") || isAffirmative(first)
if !pass && feedback == "" {
feedback = text
}
return pass, feedback, nil
}
func stateWithField(s State, field, value string) (State, error) {
var obj map[string]any
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
obj[field] = value
return stateWithObject(s, obj)
}
obj = map[string]any{field: value}
if len(s.Data) > 0 {
obj["data"] = s.String()
}
return stateWithObject(s, obj)
}
func stateWithVerification(s State, passed bool, feedback string, attempts int) (State, error) {
var obj map[string]any
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
obj["verification_passed"] = passed
obj["verification_feedback"] = feedback
obj["verification_attempts"] = attempts
return stateWithObject(s, obj)
}
obj = map[string]any{
"data": s.String(),
"verification_passed": passed,
"verification_feedback": feedback,
"verification_attempts": attempts,
}
return stateWithObject(s, obj)
}
func stateWithObject(s State, obj map[string]any) (State, error) {
b, err := json.Marshal(obj)
if err != nil {
return s, err
}
s.Data = b
return s, nil
}
-96
View File
@@ -1,96 +0,0 @@
package flow
import (
"context"
"strings"
"testing"
)
func TestVerifyPassesFirstTry(t *testing.T) {
attempts := 0
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
in.Data = []byte(`{"answer":"ok"}`)
return in, nil
}, func(context.Context, State) (bool, string, error) {
return true, "looks good", nil
}, VerifyMaxAttempts(3))
out, err := step(context.Background(), State{})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 1 {
t.Fatalf("body attempts = %d, want 1", attempts)
}
var got map[string]any
if err := out.Scan(&got); err != nil {
t.Fatalf("scan output: %v", err)
}
if got["verification_passed"] != true {
t.Fatalf("verification_passed = %v, want true", got["verification_passed"])
}
}
func TestVerifyRetriesWithFeedback(t *testing.T) {
attempts := 0
var secondInput map[string]string
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
if attempts == 2 {
if err := in.Scan(&secondInput); err != nil {
t.Fatalf("scan second input: %v", err)
}
}
in.Data = []byte(`{"answer":"draft"}`)
return in, nil
}, func(_ context.Context, _ State) (bool, string, error) {
return attempts >= 2, "include citations", nil
}, VerifyMaxAttempts(3))
out, err := step(context.Background(), State{Data: []byte(`{"topic":"agents"}`)})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 2 {
t.Fatalf("body attempts = %d, want 2", attempts)
}
if secondInput["feedback"] != "include citations" {
t.Fatalf("feedback = %q, want include citations", secondInput["feedback"])
}
if !strings.Contains(out.String(), `"verification_passed":true`) {
t.Fatalf("output missing successful verification annotation: %s", out.String())
}
}
func TestVerifyExhaustsAttemptsReturnsLastOutput(t *testing.T) {
attempts := 0
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
in.Data = []byte(`{"answer":"still wrong"}`)
return in, nil
}, func(context.Context, State) (bool, string, error) {
return false, "try again", nil
}, VerifyMaxAttempts(2))
out, err := step(context.Background(), State{})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 2 {
t.Fatalf("body attempts = %d, want 2", attempts)
}
var got map[string]any
if err := out.Scan(&got); err != nil {
t.Fatalf("scan output: %v", err)
}
if got["verification_passed"] != false {
t.Fatalf("verification_passed = %v, want false", got["verification_passed"])
}
if got["verification_feedback"] != "try again" {
t.Fatalf("verification_feedback = %v, want try again", got["verification_feedback"])
}
if got["verification_attempts"] != float64(2) {
t.Fatalf("verification_attempts = %v, want 2", got["verification_attempts"])
}
}
+16 -118
View File
@@ -28,7 +28,6 @@ package a2a
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -141,11 +140,8 @@ func (g *Gateway) Handler() http.Handler {
// Per-agent card (served at the agent's url and at its well-known path).
mux.HandleFunc("GET /agents/{name}", g.handleCard)
mux.HandleFunc("GET /agents/{name}/.well-known/agent.json", g.handleCard)
mux.HandleFunc("GET /agents/{name}/skills/{skill}", g.handleSkillCard)
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent.json", g.handleSkillCard)
// Per-agent JSON-RPC endpoint.
mux.HandleFunc("POST /agents/{name}", g.handleRPC)
mux.HandleFunc("POST /agents/{name}/skills/{skill}", g.handleSkillRPC)
// Top-level well-known: serve the single agent's card if there's
// exactly one, otherwise point to the directory.
mux.HandleFunc("GET /.well-known/agent.json", g.handleWellKnown)
@@ -180,8 +176,6 @@ type Provider struct {
type Capabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
TaskResubscribe bool `json:"taskResubscribe"`
InputRequired bool `json:"inputRequired"`
}
// Skill is a capability advertised on the Agent Card.
@@ -344,17 +338,23 @@ func Card(name, url, description string, services []string) AgentCard {
description = "Go Micro agent"
}
}
skills := skillsFromServices(services)
return AgentCard{
Name: name,
Description: description,
URL: url,
Version: "1.0.0",
ProtocolVersion: protocolVersion,
Capabilities: Capabilities{Streaming: true, PushNotifications: true, TaskResubscribe: true, InputRequired: true},
Name: name,
Description: description,
URL: url,
Version: "1.0.0",
ProtocolVersion: protocolVersion,
Capabilities: Capabilities{Streaming: true, PushNotifications: true},
// The agent converses over a single Chat endpoint; advertise that
// as one skill, tagged with the services it manages.
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: skills,
Skills: []Skill{{
ID: "chat",
Name: "Chat",
Description: "Converse with the agent to operate its services.",
Tags: services,
}},
}
}
@@ -371,19 +371,6 @@ func (g *Gateway) lookupCard(name string) (AgentCard, bool) {
return g.card(name, meta), true
}
func (g *Gateway) lookupSkillCard(name, skillID string) (AgentCard, Skill, bool) {
card, ok := g.lookupCard(name)
if !ok {
return AgentCard{}, Skill{}, false
}
for _, skill := range card.Skills {
if skill.ID == skillID {
return card, skill, true
}
}
return AgentCard{}, Skill{}, false
}
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
@@ -406,17 +393,6 @@ func (g *Gateway) handleCard(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, card)
}
func (g *Gateway) handleSkillCard(w http.ResponseWriter, r *http.Request) {
card, skill, ok := g.lookupSkillCard(r.PathValue("name"), r.PathValue("skill"))
if !ok {
http.NotFound(w, r)
return
}
card.URL = g.opts.BaseURL + "/agents/" + r.PathValue("name") + "/skills/" + skill.ID
card.Skills = []Skill{skill}
writeJSON(w, http.StatusOK, card)
}
func (g *Gateway) handleWellKnown(w http.ResponseWriter, r *http.Request) {
cards, err := g.cards()
if err != nil {
@@ -445,18 +421,6 @@ func (g *Gateway) handleRPC(w http.ResponseWriter, r *http.Request) {
})
}
func (g *Gateway) handleSkillRPC(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
_, skill, ok := g.lookupSkillCard(name, r.PathValue("skill"))
if !ok {
writeRPC(w, nil, nil, &rpcError{Code: errInvalidParams, Message: "unknown agent skill: " + name + "/" + r.PathValue("skill")})
return
}
g.disp.serve(w, r, func(ctx context.Context, text string) (string, error) {
return g.callAgent(ctx, name, skillPrompt(skill, text))
})
}
// dispatcher handles A2A JSON-RPC requests against an Invoke function and
// retains recent tasks for tasks/get. It is shared by the gateway (one
// per registry) and embedded agents (one per agent).
@@ -492,7 +456,7 @@ func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, inv
d.send(requestContext(r.Context()), w, req, invoke)
case "message/stream":
if streamInvoke != nil {
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke, invoke)
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke)
return
}
d.stream(requestContext(r.Context()), w, req, invoke)
@@ -541,7 +505,7 @@ func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcR
}
}
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke, fallback Invoke) {
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke) {
var p sendParams
if err := json.Unmarshal(req.Params, &p); err != nil {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
@@ -554,10 +518,6 @@ func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, re
}
stream, err := invoke(ctx, text)
if err != nil {
if errors.Is(err, ai.ErrStreamingUnsupported) && fallback != nil {
d.stream(ctx, w, req, fallback)
return
}
writeRPC(w, req.ID, nil, &rpcError{Code: errInternal, Message: err.Error()})
return
}
@@ -893,68 +853,6 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
}
}
func skillsFromServices(services []string) []Skill {
if len(services) == 0 {
return []Skill{{ID: "chat", Name: "Chat", Description: "Converse with the agent to operate its services."}}
}
seen := map[string]bool{}
var skills []Skill
for _, service := range services {
service = strings.TrimSpace(service)
if service == "" {
continue
}
id := skillID(service)
if id == "" || seen[id] {
continue
}
seen[id] = true
skills = append(skills, Skill{
ID: id,
Name: skillName(service),
Description: fmt.Sprintf("Operate the %s service through this agent.", service),
Tags: []string{service},
})
}
if len(skills) == 0 {
return []Skill{{ID: "chat", Name: "Chat", Description: "Converse with the agent to operate its services."}}
}
return skills
}
func skillID(service string) string {
service = strings.ToLower(strings.TrimSpace(service))
var b strings.Builder
dash := false
for _, r := range service {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
return strings.Trim(b.String(), "-")
}
func skillName(service string) string {
parts := strings.FieldsFunc(service, func(r rune) bool { return r == '-' || r == '_' || r == '.' || r == '/' || r == ' ' })
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func skillPrompt(skill Skill, text string) string {
return fmt.Sprintf("Use the %q skill (%s) for this request.\n\n%s", skill.Name, skill.ID, text)
}
func textOf(parts []Part) string {
var b strings.Builder
for _, p := range parts {
+3 -101
View File
@@ -51,7 +51,7 @@ func newGatewayWithAgent(t *testing.T) (*httptest.Server, func()) {
server.Name("echo"),
server.Address("127.0.0.1:0"),
server.Registry(reg),
server.Metadata(map[string]string{"type": "agent", "services": "task,project"}),
server.Metadata(map[string]string{"type": "agent", "services": ""}),
)
if err := pb.RegisterAgentHandler(srv, echoAgent{}); err != nil {
t.Fatalf("register agent handler: %v", err)
@@ -88,43 +88,8 @@ func TestAgentCardFromRegistry(t *testing.T) {
if card.URL != "http://gw/agents/echo" {
t.Errorf("card url = %q", card.URL)
}
if card.ProtocolVersion == "" {
t.Errorf("card missing protocolVersion: %+v", card)
}
if !card.Capabilities.TaskResubscribe || !card.Capabilities.InputRequired {
t.Errorf("card capabilities = %+v, want task resubscribe and input-required advertised", card.Capabilities)
}
if got := skillIDs(card.Skills); strings.Join(got, ",") != "task,project" {
t.Errorf("skill IDs = %v, want [task project]", got)
}
}
func TestSkillEndpointServesFocusedCardAndRoutesRPC(t *testing.T) {
ts, cleanup := newGatewayWithAgent(t)
defer cleanup()
resp, err := http.Get(ts.URL + "/agents/echo/skills/task/.well-known/agent.json")
if err != nil {
t.Fatalf("get skill card: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("skill card status = %d", resp.StatusCode)
}
var card AgentCard
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
t.Fatalf("decode skill card: %v", err)
}
if card.URL != "http://gw/agents/echo/skills/task" || len(card.Skills) != 1 || card.Skills[0].ID != "task" {
t.Fatalf("skill card = %+v, want task-only card at skill URL", card)
}
task := rpcTask(t, ts.URL+"/agents/echo/skills/task", `{
"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
"parts":[{"kind":"text","text":"ping"}]}}}`)
if task.Status.State != stateCompleted || textOf(task.Artifacts[0].Parts) != "pong" {
t.Fatalf("skill task = %+v, want completed pong", task)
if card.ProtocolVersion == "" || len(card.Skills) == 0 {
t.Errorf("card missing protocolVersion or skills: %+v", card)
}
}
@@ -468,61 +433,6 @@ func TestMessageStreamChunksPropagatesCancellationAndClosesStream(t *testing.T)
}
}
func TestMessageStreamChunksFallsBackWhenUnsupported(t *testing.T) {
d := newDispatcher()
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
rr := httptest.NewRecorder()
var streamed bool
var fallbackText string
d.serveWithStream(rr, req, func(ctx context.Context, text string) (string, error) {
fallbackText = text
return "pong", nil
}, func(ctx context.Context, text string) (ai.Stream, error) {
streamed = true
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
})
if !streamed {
t.Fatal("stream invoke was not attempted")
}
if fallbackText != "ping" {
t.Fatalf("fallback text = %q, want ping", fallbackText)
}
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
var events []struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = strings.TrimPrefix(line, "data: ")
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
events = append(events, event)
}
if len(events) != 1 {
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
}
if events[0].Error != nil {
t.Fatalf("fallback event error: %+v", events[0].Error)
}
if events[0].Result.Status.State != stateCompleted || textOf(events[0].Result.Artifacts[0].Parts) != "pong" {
t.Fatalf("fallback task = %+v, want completed pong", events[0].Result)
}
}
func TestTasksResubscribeStreamsCurrentAndSubsequentEvents(t *testing.T) {
d := newDispatcher()
initial := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateWorking, Timestamp: time.Now().UTC().Format(time.RFC3339)}}
@@ -734,11 +644,3 @@ func rpcTask(t *testing.T, url, body string) Task {
}
return resp.Result
}
func skillIDs(skills []Skill) []string {
ids := make([]string, 0, len(skills))
for _, skill := range skills {
ids = append(ids, skill.ID)
}
return ids
}
+1 -74
View File
@@ -1,13 +1,11 @@
package a2a
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
@@ -137,77 +135,6 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
return &task, nil
}
// Resubscribe reconnects to a retained or active task stream and returns task
// snapshots as the remote agent emits updates. The returned channel is closed
// when the task reaches a terminal state or ctx is canceled.
func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan Task, <-chan error) {
tasks := make(chan Task, 8)
errs := make(chan error, 1)
go func() {
defer close(tasks)
defer close(errs)
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": uuid.New().String(),
"method": "tasks/resubscribe",
"params": getParams{ID: taskID},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
errs <- err
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := c.http.Do(req)
if err != nil {
errs <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errs <- fmt.Errorf("tasks/resubscribe: status %d", resp.StatusCode)
return
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
var out struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(payload), &out); err != nil {
errs <- err
return
}
if out.Error != nil {
errs <- fmt.Errorf("a2a tasks/resubscribe: %s (%d)", out.Error.Message, out.Error.Code)
return
}
select {
case <-ctx.Done():
errs <- ctx.Err()
return
case tasks <- out.Result:
}
if terminal(out.Result.Status.State) {
return
}
}
if err := scanner.Err(); err != nil {
errs <- err
}
}()
return tasks, errs
}
// SetPushNotificationConfig asks the remote agent to POST updates for taskID to cfg.URL.
func (c *Client) SetPushNotificationConfig(ctx context.Context, taskID string, cfg PushNotificationConfig) error {
_, err := c.call(ctx, "tasks/pushNotificationConfig/set", pushConfigParams{
@@ -266,7 +193,7 @@ func (c *Client) call(ctx context.Context, method string, params any) (json.RawM
func terminal(state string) bool {
switch state {
case "completed", "failed", "canceled", "rejected", "input-required":
case "completed", "failed", "canceled", "rejected":
return true
}
return false
-56
View File
@@ -3,10 +3,8 @@ package a2a
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -112,57 +110,3 @@ func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
t.Fatal("timed out waiting for push update")
}
}
func TestClientResubscribeStreamsRetainedAndLiveTask(t *testing.T) {
d := newDispatcher()
initial := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateWorking, Timestamp: time.Now().UTC().Format(time.RFC3339)}}
d.store(initial)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d.serve(w, r, func(context.Context, string) (string, error) { return "", nil })
}))
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
tasks, errs := NewClient(ts.URL).Resubscribe(ctx, initial.ID)
first := <-tasks
if first.ID != initial.ID || first.Status.State != stateWorking {
t.Fatalf("first resubscribe task = %+v, want retained working task", first)
}
final := &Task{ID: initial.ID, ContextID: initial.ContextID, Kind: "task", Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)}, Artifacts: []Artifact{textArtifact("done")}}
d.store(final)
second := <-tasks
if second.ID != final.ID || second.Status.State != stateCompleted || textOf(second.Artifacts[0].Parts) != "done" {
t.Fatalf("second resubscribe task = %+v, want live completed task", second)
}
if _, ok := <-tasks; ok {
t.Fatal("resubscribe task channel stayed open after terminal update")
}
select {
case err := <-errs:
if err != nil {
t.Fatalf("resubscribe error = %v", err)
}
default:
}
}
func TestClientSendMessageReturnsInputRequiredTask(t *testing.T) {
card := Card("solo", "http://localhost:4000", "", []string{"task"})
h := NewAgentHandler(card, func(context.Context, string) (string, error) {
return "", errors.New("input-required: provide approval code")
})
ts := httptest.NewServer(h)
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
task, err := NewClient(ts.URL).SendMessage(ctx, Message{Parts: []Part{{Kind: "text", Text: "approve?"}}})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if task.Status.State != stateInputRequired || !strings.Contains(textOf(task.Artifacts[0].Parts), "provide approval code") {
t.Fatalf("task = %+v, want input-required handoff", task)
}
}
-129
View File
@@ -1,129 +0,0 @@
package mcp
import (
"encoding/json"
"net/http"
"strings"
)
// HandlerOption configures NewHandler.
type HandlerOption func(*handlerOptions)
type handlerOptions struct {
serverName, serverVersion, protocolVersion string
}
// WithServerInfo sets the name/version advertised in the initialize response.
func WithServerInfo(name, version string) HandlerOption {
return func(o *handlerOptions) { o.serverName, o.serverVersion = name, version }
}
// WithProtocolVersion sets the MCP protocol version advertised in initialize.
func WithProtocolVersion(v string) HandlerOption {
return func(o *handlerOptions) { o.protocolVersion = v }
}
// NewHandler returns an http.Handler serving the MCP protocol over HTTP as
// JSON-RPC 2.0 (initialize, ping, notifications/*, tools/list, tools/call),
// backed by the resolver. Mount it on your own server (e.g. POST /mcp): the
// gateway provides the protocol; you keep your routes, middleware and any
// human-facing docs page.
func NewHandler(r Resolver, opts ...HandlerOption) http.Handler {
o := handlerOptions{serverName: "go-micro-mcp", serverVersion: "1.0.0", protocolVersion: "2024-11-05"}
for _, fn := range opts {
fn(&o)
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var rpc struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(req.Body).Decode(&rpc); err != nil {
writeRPCError(w, nil, ParseError, "Parse error", err.Error())
return
}
// Notifications (and any id-less request) expect no response body.
if strings.HasPrefix(rpc.Method, "notifications/") || len(rpc.ID) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
ctx := req.Context()
switch rpc.Method {
case "initialize":
writeRPCResult(w, rpc.ID, map[string]interface{}{
"protocolVersion": o.protocolVersion,
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
"serverInfo": map[string]interface{}{"name": o.serverName, "version": o.serverVersion},
})
case "ping":
writeRPCResult(w, rpc.ID, map[string]interface{}{})
case "tools/list":
tools, err := r.List(ctx)
if err != nil {
writeRPCError(w, rpc.ID, InternalError, "Failed to list tools", err.Error())
return
}
list := make([]map[string]interface{}, 0, len(tools))
for _, t := range tools {
list = append(list, map[string]interface{}{
"name": t.Name, "description": t.Description, "inputSchema": t.InputSchema,
})
}
writeRPCResult(w, rpc.ID, map[string]interface{}{"tools": list})
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal(rpc.Params, &p); err != nil {
writeRPCError(w, rpc.ID, InvalidParams, "Invalid params", err.Error())
return
}
res, err := r.Call(ctx, p.Name, p.Arguments)
if err != nil {
// Protocol/pre-check failure -> JSON-RPC error. An *RPCError
// carries a specific code; anything else is InternalError.
if rpcErr, ok := err.(*RPCError); ok {
writeRPCError(w, rpc.ID, rpcErr.Code, rpcErr.Message, rpcErr.Data)
} else {
writeRPCError(w, rpc.ID, InternalError, "Tool call failed", err.Error())
}
return
}
result := map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": res.Text}},
}
if res.IsError {
result["isError"] = true
}
writeRPCResult(w, rpc.ID, result)
default:
writeRPCError(w, rpc.ID, MethodNotFound, "Method not found", rpc.Method)
}
})
}
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": rawOrNull(id), "result": result})
}
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string, data interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": rawOrNull(id), "error": map[string]interface{}{"code": code, "message": msg, "data": data}})
}
func rawOrNull(id json.RawMessage) interface{} {
if len(id) == 0 {
return nil
}
return id
}
-131
View File
@@ -1,131 +0,0 @@
package mcp
import (
"context"
"sync"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
)
// CallResult is the outcome of a successful tool dispatch. A tool that ran but
// produced an error sets IsError — per the MCP spec this is returned as a
// tools/call result with isError:true, not a JSON-RPC protocol error.
type CallResult struct {
Text string
IsError bool
}
// Error lets the package's RPCError (see stdio.go) be returned by a resolver
// to signal a protocol/pre-check failure with a specific JSON-RPC code; the
// handler maps it straight to the JSON-RPC error.
func (e *RPCError) Error() string { return e.Message }
// ToolFunc executes a manually-registered tool. Return a *CallResult for tool
// outcomes (set IsError for tool-level failures); return a non-nil error — an
// *RPCError for a specific code — for protocol/pre-check failures.
type ToolFunc func(ctx context.Context, args map[string]any) (*CallResult, error)
// Resolver supplies the gateway's tools and executes calls. Swapping the
// resolver changes where tools come from without touching the MCP protocol or
// transport:
//
// - NewManualResolver: tools you register explicitly (full product control,
// including tools that are not go-micro services, executed via your own
// logic — auth, metering, …).
// - NewRegistryResolver: tools auto-discovered from registered services.
//
// The built-in store/broker tools are intentionally NOT exposed by any
// resolver — they remain a development convenience on the legacy Serve() path.
type Resolver interface {
// List returns the current tool catalog.
List(ctx context.Context) ([]Tool, error)
// Call executes a tool by name with JSON arguments.
Call(ctx context.Context, name string, args map[string]any) (*CallResult, error)
}
// ManualResolver exposes an explicitly-registered set of tools.
type ManualResolver struct {
mu sync.RWMutex
order []Tool
funcs map[string]ToolFunc
}
// NewManualResolver returns an empty manual resolver.
func NewManualResolver() *ManualResolver {
return &ManualResolver{funcs: map[string]ToolFunc{}}
}
// Add registers (or replaces) a tool and its handler. Returns the resolver for
// chaining.
func (m *ManualResolver) Add(t Tool, fn ToolFunc) *ManualResolver {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.funcs[t.Name]; ok {
for i := range m.order {
if m.order[i].Name == t.Name {
m.order[i] = t
}
}
} else {
m.order = append(m.order, t)
}
m.funcs[t.Name] = fn
return m
}
// List returns the registered tools.
func (m *ManualResolver) List(_ context.Context) ([]Tool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]Tool, len(m.order))
copy(out, m.order)
return out, nil
}
// Call runs the handler registered for name.
func (m *ManualResolver) Call(ctx context.Context, name string, args map[string]any) (*CallResult, error) {
m.mu.RLock()
fn, ok := m.funcs[name]
m.mu.RUnlock()
if !ok {
return nil, &RPCError{Code: InvalidParams, Message: "Tool not found: " + name, Data: name}
}
return fn(ctx, args)
}
// RegistryResolver auto-discovers tools from registered go-micro services and
// executes them over RPC. It exposes only services — never the internal
// store/broker tools.
type RegistryResolver struct {
tools *ai.Tools
}
// NewRegistryResolver discovers services from reg and calls them with cl.
func NewRegistryResolver(reg registry.Registry, cl client.Client) *RegistryResolver {
return &RegistryResolver{tools: ai.NewTools(reg, ai.ToolClient(cl))}
}
// List discovers the current service tools.
func (r *RegistryResolver) List(_ context.Context) ([]Tool, error) {
discovered, err := r.tools.Discover()
if err != nil {
return nil, err
}
out := make([]Tool, 0, len(discovered))
for _, t := range discovered {
out = append(out, Tool{
Name: t.Name,
Description: t.Description,
InputSchema: map[string]interface{}{"type": "object", "properties": t.Properties},
})
}
return out, nil
}
// Call executes a discovered service tool.
func (r *RegistryResolver) Call(ctx context.Context, name string, args map[string]any) (*CallResult, error) {
res := r.tools.Handler()(ctx, ai.ToolCall{ID: "1", Name: name, Input: args})
return &CallResult{Text: res.Content}, nil
}
-64
View File
@@ -1,64 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestManualResolverHandler(t *testing.T) {
res := NewManualResolver().
Add(Tool{Name: "echo", Description: "echoes text"},
func(_ context.Context, args map[string]interface{}) (*CallResult, error) {
s, _ := args["text"].(string)
return &CallResult{Text: "you said: " + s}, nil
}).
Add(Tool{Name: "boom", Description: "errors"},
func(_ context.Context, _ map[string]interface{}) (*CallResult, error) {
return &CallResult{Text: "kaboom", IsError: true}, nil
}).
Add(Tool{Name: "blocked", Description: "coded error"},
func(_ context.Context, _ map[string]interface{}) (*CallResult, error) {
return nil, &RPCError{Code: -32000, Message: "insufficient credits"}
})
ts := httptest.NewServer(NewHandler(res))
defer ts.Close()
rpc := func(body string) (int, map[string]interface{}) {
resp, err := http.Post(ts.URL, "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("post rpc: %v", err)
}
defer resp.Body.Close()
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
if _, out := rpc(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`); len(out["result"].(map[string]interface{})["tools"].([]interface{})) != 3 {
t.Fatalf("tools/list: %v", out)
}
// tool result
_, out := rpc(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}`)
if out["result"].(map[string]interface{})["content"].([]interface{})[0].(map[string]interface{})["text"] != "you said: hi" {
t.Fatalf("echo: %v", out)
}
// tool-level error -> isError result, NOT protocol error
_, out = rpc(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"boom","arguments":{}}}`)
if out["error"] != nil || out["result"].(map[string]interface{})["isError"] != true {
t.Fatalf("boom should be isError result: %v", out)
}
// coded protocol error -> JSON-RPC error with the code
_, out = rpc(`{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"blocked","arguments":{}}}`)
if out["error"] == nil || int(out["error"].(map[string]interface{})["code"].(float64)) != -32000 {
t.Fatalf("blocked should be -32000: %v", out)
}
// notification -> 204, no body
code, _ := rpc(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)
if code != http.StatusNoContent {
t.Fatalf("notification status = %d, want 204", code)
}
}
-12
View File
@@ -29,18 +29,6 @@ consistent across providers.
The companion `TestAgentProviderConformanceFakeError` keeps provider error
propagation covered locally without relying on external credentials.
## Local no-secret conformance
Use `make provider-conformance-mock` to run the same provider conformance harness
through the deterministic mock provider. That target requires no API keys and is
what `make harness` delegates to after the 0→1 and 0→hero scenarios, so every PR
continues to exercise the provider-facing agent/tool contract without spending
live model credits.
Use `make provider-conformance` when you want the live-provider sweep: providers
without keys are skipped, and configured providers must satisfy the same harness
contract.
## Scheduled CI
The daily/manual `Harness (E2E)` workflow runs the same matrix with
-13
View File
@@ -122,19 +122,6 @@ database "agent", table "{name}":
history — conversation history
```
## Durable Ask / StreamAsk runs
Agents can opt into the same checkpoint backend used by flows with
`micro.AgentWithCheckpoint(...)`. When enabled, each `Ask` or `StreamAsk` run is
persisted with its input, terminal status, response, and tool-call records. If
the process or transport drops after a tool has completed but before the model
returns a final answer, restart the agent with the same checkpoint store and
call `micro.AgentResume(ctx, ag, runID)` or
`micro.AgentResumeStreamAsk(ctx, ag, runID)`.
Completed tool calls are served from the checkpoint instead of being executed
again, while guardrails such as `MaxSteps`, loop detection, approval pauses, and
`request_input` pauses continue to apply to the resumed run.
## Built-in Capabilities
Beyond its scoped service tools, every agent gets two built-in tools. They are not service endpoints — they are capabilities the agent has over itself and over other agents. They are plain tools wired into the agent's tool handler; there is no separate harness, loop engine, or graph. The LLM calls them exactly like any other tool.
+3 -37
View File
@@ -10,26 +10,6 @@ and can stop or revert anything at any time.
> **services → agents → workflows**. Judge each change against it — work that
> doesn't move toward that lifecycle isn't an improvement, however clean.
## The pipeline (planner → generator → evaluator)
The development process is an operational instance of the long-running-agent
harness pattern ([Anthropic on harness design](https://www.anthropic.com/engineering/harness-design-long-running-apps)) —
a planner, a generator, and a *separate* evaluator — distributed across GitHub
Actions instead of subagents. Each role is a workflow:
| Role | Workflow (action name) | What it does |
|------|------------------------|--------------|
| **Planner** | `loop-architect.yml`*Loop: Architect (Planner)* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`PRIORITIES.md`](PRIORITIES.md). Decides *what*. |
| **Generator** | `loop-builder.yml`*Loop: Builder (Generator)* | Builds the top open queue item as a single-concern PR (via Codex) and self-merges on green CI. Does the work. |
| **Evaluator** | `harness.yml`*Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | On harness failure, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Coherence** | `loop-devrel.yml`*Loop: DevRel* | Keeps README/website/docs/blog aligned with the North Star. |
| **Release** | `loop-release.yml`*Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
Generation is separated from evaluation on purpose: an agent grading its own work
reliably over-rates it, so **CI and the harness — not the builder — are the gate**.
The human sets direction and owns the calls that need taste (see Guardrails).
## Autonomy
Full autonomy, **no approval gates**. Each increment: Claude Code picks the work,
@@ -92,7 +72,7 @@ Grounded in real signal, never speculative rewrites. Each cycle draws from:
tracking issue for each increment and dispatches Codex there. It needs a
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
without that secret the workflow deliberately no-ops to avoid ignored bot
comments. See `.github/workflows/loop-builder.yml` and the mechanics
comments. See `.github/workflows/continuous-improvement.yml` and the mechanics
below.
## How the durable loop works (mechanics)
@@ -147,13 +127,13 @@ The hourly loop ships increments; two periodic passes keep the *whole* heading i
the right direction. Both use the same mechanism (fresh issue → `@codex` →
output) but produce direction and coherence, not just code.
- **DevRel — daily** (`.github/workflows/loop-devrel.yml`). Audits the public
- **DevRel — daily** (`.github/workflows/devrel-review.yml`). Audits the public
surface (README, website landing + docs, blog) for coherence with the North
Star, README crispness, and blog-worthy material. **Autonomy boundary:** safe
factual-alignment and crispness fixes auto-merge like any increment;
brand/positioning copy and blog drafts are *surfaced in a report* for the
human, never auto-merged.
- **Architect — continuous (hourly)** (`.github/workflows/loop-architect.yml`).
- **Architect — continuous (hourly)** (`.github/workflows/architecture-review.yml`).
The *founder lens*, running alongside the builders. Each run it **tracks live
state** (what just merged, what's in flight), **prioritizes the roadmap**
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
@@ -174,20 +154,6 @@ roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in e
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
## Failure triage (the feedback loop)
The loop also closes on its own failures. `.github/workflows/loop-triage.yml`
fires when the live provider-conformance harness finishes with `conclusion:
failure` (scheduled/manual runs only), and dispatches Codex to **triage** the
failing run: read the logs, root-cause each distinct failure, **dedupe** against
open issues (comment "recurred" rather than filing a duplicate), and file a scoped
`codex`/`enhancement` issue for each genuine, self-contained defect — which the
increment loop then builds and the next harness run verifies. Transient flakes
(live-model latency, provider outages) are ignored; anything needing a breaking or
architectural change is escalated as `needs-human` instead of auto-built. This is
the hill-climbing layer: CI/harness failures become fixes with no human in the
middle, short of a decision that's genuinely the human's.
## Stop / redirect
- In-session: `CronDelete <id>` (or end the session).
+4 -11
View File
@@ -19,18 +19,11 @@ redirect the loop; direction always wins.
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
## Developer experience (ranked)
1. **Write a “Your First Agent” walkthrough that takes a developer from services to an agent end to end** ([#3561](https://github.com/micro/go-micro/issues/3561)) — the current adoption gap is the on-ramp, not another internal primitive. This should be the highest-value Now/DX task: a walkable, CI-verifiable first-agent path that proves “building an agent is as easy as building a service” and gives new users an immediate win.
2. **Lead install docs with `@latest` now that the phantom-module retraction is published** ([#3565](https://github.com/micro/go-micro/issues/3565)) — install friction blocks every later experience. Make the first command current, boring, and consistent across README/website/docs so revived usage does not stall before scaffold/run.
3. **Surface runnable examples from getting-started and the guides** ([#3562](https://github.com/micro/go-micro/issues/3562)) — the blog canon now has a concrete support-agent story, but developers need obvious links from docs into runnable code. Tie examples to the 0→1 and 0→hero paths so “read → run → modify one thing” is discoverable.
4. **Reorder the guides nav to lead with doing before concepts** ([#3563](https://github.com/micro/go-micro/issues/3563)) — the framework is deep, but the first impression should be action: quickstart, first agent, examples, debugging, then architecture. This is small, CI-checkable wayfinding work with outsized adoption value.
5. **Add a “Debugging your agent” guide focused on the dev workflow** ([#3564](https://github.com/micro/go-micro/issues/3564)) — the inner loop is scaffold → run → chat → inspect → deploy. Document how to see tool calls, run history, provider failures, guardrail refusals, and flow handoffs before adding more depth that users cannot diagnose.
6. **Prevent duplicate tool side effects in the plan/delegate harness** ([#3559](https://github.com/micro/go-micro/issues/3559)) — correctness still matters where it protects real user trust. Plan/delegate is central to the services → agents lifecycle, and duplicate side effects undermine the “agent as dependable service” story.
7. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — keep interop conformance trustworthy without letting it dominate the adoption queue. This is scoped, testable, and protects the A2A promise developers see in the README and site.
8. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — after the on-ramp items, the highest-value remaining Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
9. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
10. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
1. **Add durable agent run checkpoint and resume** ([#3306](https://github.com/micro/go-micro/issues/3306)) — the CLI inspection foothold has shipped, so the highest-value Next-roadmap seam is making agent loops resumable like flows: long-running work must survive restarts without unsafe replay or hidden state loss before deeper streaming and observability can be trusted.
2. **Broaden provider-backed AI streaming coverage** ([#3315](https://github.com/micro/go-micro/issues/3315)) — after checkpoint/resume, extend tested `ai.Stream` coverage across remaining providers so `micro chat`, A2A streaming, and long-running agent interactions behave consistently instead of depending on adapter-specific gaps.
3. **Emit agent RunInfo as OpenTelemetry spans** ([#3316](https://github.com/micro/go-micro/issues/3316)) — once runs are durable and streaming paths are consistent, turn agent run timelines into correlated spans so operators can inspect model calls, tool calls, failures, and run IDs through the same observability surface as services and flows.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
@@ -1,187 +0,0 @@
// A2A stream fallback harness.
//
// It exercises the gateway boundary that fronts an agent over A2A. The agent is
// configured with tools and memory, but its model streaming path deliberately
// reports ai.ErrStreamingUnsupported; the A2A gateway must fall back to the
// normal Ask path and still complete the same tool-calling run.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if req.Prompt == "" {
return nil, errors.New("missing prompt")
}
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
}
if len(req.Tools) == 0 || m.opts.ToolHandler == nil {
return nil, errors.New("missing tools or tool handler")
}
res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}})
if res.Content == "" {
return nil, errors.New("empty tool result")
}
return &ai.Response{Reply: "fallback completed", Answer: res.Content, ToolCalls: []ai.ToolCall{{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}, Result: res.Content}}}, nil
}
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
env := map[string]string{
"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY", "groq": "GROQ_API_KEY", "mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY", "atlascloud": "ATLASCLOUD_API_KEY",
}[provider]
return os.Getenv(env)
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
apiKey := ""
if *provider == "mock" {
ai.Register("mock", newMock)
} else {
apiKey = providerKey(*provider)
if apiKey == "" {
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
return
}
}
fmt.Printf("\n\033[1mA2A streaming fallback conformance (provider: %s)\033[0m\n", *provider)
reg := registry.NewMemoryRegistry()
st := store.NewMemoryStore()
var sawTool, sawRunInfo bool
agentOpts := []agent.Option{
agent.Name("a2a-fallback"),
agent.Provider(*provider),
agent.APIKey(apiKey),
agent.Prompt("Use fallback_echo exactly once with value a2a-fallback, then answer with the tool result."),
agent.WithRegistry(reg),
agent.WithStore(st),
agent.WithMemory(agent.NewInMemory(8)),
agent.ModelCallTimeout(45 * time.Second),
agent.WithTool("fallback_echo", "Echo the A2A fallback marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok || info.RunID == "" || info.Agent != "a2a-fallback" {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "a2a-fallback" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"a2a-fallback-ok"}`, nil
}),
}
agentOpts = append(agentOpts, harnessutil.AgentOptions(*provider)...)
ag := agent.New(agentOpts...)
card := a2a.Card("a2a-fallback", "http://example.invalid/a2a-fallback", "", nil)
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := ag.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
}, ag.Stream)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A fallback conformance check."}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b)
os.Exit(1)
}
if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
os.Exit(1)
}
payload, err := readSSEData(res.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !strings.Contains(payload, "a2a-fallback-ok") {
fmt.Fprintf(os.Stderr, "stream payload missing marker: %s\n", payload)
os.Exit(1)
}
if !sawTool || !sawRunInfo {
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ A2A message/stream fell back to Ask and preserved tool/run metadata\033[0m")
}
func readSSEData(r io.Reader) (string, error) {
scanner := bufio.NewScanner(r)
var payload strings.Builder
for scanner.Scan() {
line := scanner.Text()
if data, ok := strings.CutPrefix(line, "data: "); ok {
payload.WriteString(data)
payload.WriteByte('\n')
}
}
if err := scanner.Err(); err != nil {
return "", err
}
if payload.Len() == 0 {
return "", errors.New("no SSE data received")
}
if !json.Valid([]byte(strings.TrimSpace(payload.String()))) {
return "", fmt.Errorf("SSE data is not JSON: %s", payload.String())
}
return payload.String(), nil
}
+5 -8
View File
@@ -25,9 +25,10 @@ import (
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
@@ -202,9 +203,8 @@ func main() {
fmt.Println("broker connect:", err)
os.Exit(1)
}
cl := harnessutil.Client(*provider, reg)
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
mem := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(*provider)
wsSvc := new(WorkspaceService)
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
@@ -217,7 +217,7 @@ func main() {
go nt.Run()
// The onboarder agent, registered so the flow can reach it over RPC.
onboarderOpts := []agent.Option{
onboarder := agent.New(
agent.Name("onboarder"),
agent.Address("127.0.0.1:0"),
agent.Services("workspace", "notify"),
@@ -225,9 +225,7 @@ func main() {
agent.Provider(*provider),
agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
}
onboarderOpts = append(onboarderOpts, liveAgentOpts...)
onboarder := agent.New(onboarderOpts...)
)
go onboarder.Run()
defer onboarder.Stop()
@@ -240,7 +238,6 @@ func main() {
flow.Trigger("events.user.created"),
flow.Agent("onboarder"),
flow.Prompt("A new user signed up: {{.Data}}. Get them set up."),
flow.Timeout(harnessutil.LiveTimeout(*provider)),
)
if err := f.Register(reg, br, cl); err != nil {
fmt.Println("flow register:", err)
@@ -1,61 +0,0 @@
package harnessutil
import (
"fmt"
"os"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
)
const (
// LiveTimeoutEnv overrides the per-call deadline used by live-provider
// harness runs. It intentionally does not affect deterministic mock runs.
LiveTimeoutEnv = "GO_MICRO_HARNESS_LIVE_TIMEOUT"
// DefaultLiveTimeout is generous enough for slow but correct hosted models
// while still bounding genuinely stuck live conformance runs.
DefaultLiveTimeout = 5 * time.Minute
)
// LiveTimeout returns the harness per-call timeout for live providers. Mock runs
// keep their historical fast defaults by returning zero.
func LiveTimeout(provider string) time.Duration {
if provider == "mock" {
return 0
}
if raw := os.Getenv(LiveTimeoutEnv); raw != "" {
d, err := time.ParseDuration(raw)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid %s=%q; using %s\n", LiveTimeoutEnv, raw, DefaultLiveTimeout)
return DefaultLiveTimeout
}
return d
}
return DefaultLiveTimeout
}
// Client returns an in-memory-registry client. Live provider harnesses get a
// larger request timeout so an otherwise correct agent run is not cut off by the
// default 30-second RPC deadline; mock runs are unchanged.
func Client(provider string, reg registry.Registry) client.Client {
opts := []client.Option{
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
}
if d := LiveTimeout(provider); d > 0 {
opts = append(opts, client.RequestTimeout(d))
}
return client.NewClient(opts...)
}
// AgentOptions applies the same live-provider timeout to model and tool calls.
// The empty result for mock runs preserves their deterministic timing.
func AgentOptions(provider string) []agent.Option {
if d := LiveTimeout(provider); d > 0 {
return []agent.Option{agent.ModelCallTimeout(d), agent.ToolCallTimeout(d)}
}
return nil
}
@@ -1,33 +0,0 @@
package harnessutil
import (
"testing"
"time"
)
func TestLiveTimeoutLeavesMockUnchanged(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "2m")
if got := LiveTimeout("mock"); got != 0 {
t.Fatalf("LiveTimeout(mock) = %s, want 0", got)
}
if opts := AgentOptions("mock"); len(opts) != 0 {
t.Fatalf("AgentOptions(mock) length = %d, want 0", len(opts))
}
}
func TestLiveTimeoutUsesDefaultForLiveProviders(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "")
if got := LiveTimeout("atlascloud"); got != DefaultLiveTimeout {
t.Fatalf("LiveTimeout(live) = %s, want %s", got, DefaultLiveTimeout)
}
if opts := AgentOptions("atlascloud"); len(opts) != 2 {
t.Fatalf("AgentOptions(live) length = %d, want 2", len(opts))
}
}
func TestLiveTimeoutCanBeOverridden(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "90s")
if got := LiveTimeout("anthropic"); got != 90*time.Second {
t.Fatalf("LiveTimeout override = %s, want 90s", got)
}
}
+7 -12
View File
@@ -26,9 +26,10 @@ import (
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
@@ -240,9 +241,8 @@ func runPlanDelegate(provider string) error {
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
reg := registry.NewMemoryRegistry()
cl := harnessutil.Client(provider, reg)
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
mem := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
// Real services.
taskSvc := new(TaskService)
@@ -260,30 +260,26 @@ func runPlanDelegate(provider string) error {
go notify.Run()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
commsOpts := []agent.Option{
comms := agent.New(
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt("You handle outbound notifications. Use the notify service."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
}
commsOpts = append(commsOpts, liveAgentOpts...)
comms := agent.New(commsOpts...)
)
go comms.Run()
defer comms.Stop()
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
conductorOpts := []agent.Option{
conductor := agent.New(
agent.Name("conductor"),
agent.Address("127.0.0.1:0"),
agent.Services("task"),
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
}
conductorOpts = append(conductorOpts, liveAgentOpts...)
conductor := agent.New(conductorOpts...)
)
go conductor.Run()
defer conductor.Stop()
@@ -307,7 +303,6 @@ func runPlanDelegate(provider string) error {
f := flow.New("zero-to-hero",
flow.Agent("conductor"),
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
flow.Timeout(harnessutil.LiveTimeout(provider)),
)
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
return fmt.Errorf("flow register: %w", err)
+10 -24
View File
@@ -7,24 +7,16 @@ suite is safe for local development, forks, and scheduled CI.
## What it exercises
`go run ./internal/harness/provider-conformance` fans out over the provider-facing
agent test and the harnesses in `internal/harness`:
`go run ./internal/harness/provider-conformance` fans out over the harnesses in
`internal/harness`:
- `agent` — provider tool-call conformance through `agent.Ask`, including run metadata propagation.
- `universe` — service discovery plus agent tool calls over the real runtime.
- `agent-flow` — a workflow event that drives an agent to call services.
- `plan-delegate` — plan persistence plus agent-to-agent delegation and service
calls.
- `a2a-stream-fallback` — A2A `message/stream` through the gateway, including
fallback from unsupported provider streaming to the tool-calling `Ask` path while
preserving run metadata.
The command also emits the registered provider capability matrix so the run shows
which providers advertise model, image, video, and streaming support. Console output
and summary artifacts label each harness with the phase it is proving (for example,
model call + tool call, workflow event + tool call, or streaming fallback + tool
call), so provider failures identify the failed lifecycle phase instead of only the
provider name.
which providers advertise model, image, video, and streaming support.
## Local usage
@@ -56,8 +48,7 @@ Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable
| AtlasCloud | `ATLASCLOUD_API_KEY` |
Use `-require-configured` when you want a selected provider without a key to fail
instead of skip. This is useful for manually checking that a required provider
secret is actually wired into CI before relying on that provider as covered:
instead of skip:
```sh
go run ./internal/harness/provider-conformance \
@@ -68,18 +59,13 @@ go run ./internal/harness/provider-conformance \
## Scheduled CI behavior
The `Harness (E2E)` workflow runs on pushes and pull requests with deterministic
mock LLMs, including `provider-conformance -providers mock`. On the daily
schedule and manual dispatch it also runs the live provider conformance job. A
manual dispatch can narrow `providers` or `harnesses`, and can set
`require_configured=true` to fail fast when an expected repository secret is
missing; scheduled runs keep the safe default and report missing keys as skips.
That job:
mock LLMs. On the daily schedule and manual dispatch it also runs the live
provider conformance job. That job:
1. runs the same `agent`, `universe`, `agent-flow`, `plan-delegate`, and `a2a-stream-fallback` harness list,
2. reads the provider keys from repository secrets,
3. skips providers whose secrets are absent,
4. fails when any configured provider fails a harness, and
5. uploads JSON and Markdown coverage artifacts for the run.
1. reads the provider keys from repository secrets,
2. skips providers whose secrets are absent,
3. fails when any configured provider fails a harness, and
4. uploads JSON and Markdown coverage artifacts for the run.
The job also appends the Markdown summary and capability matrix to the GitHub
Actions step summary, making configured, skipped, and failed provider coverage
+9 -86
View File
@@ -34,16 +34,6 @@ import (
_ "go-micro.dev/v6/ai/together"
)
const defaultHarnesses = "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
var harnessPhases = map[string]string{
"agent": "model call + tool call",
"universe": "service discovery + tool call",
"agent-flow": "workflow event + tool call",
"plan-delegate": "plan persistence + delegation + tool call",
"a2a-stream-fallback": "streaming fallback + tool call",
}
var providerEnv = map[string]string{
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
@@ -55,8 +45,8 @@ var providerEnv = map[string]string{
}
func main() {
providersFlag := flag.String("providers", defaultProviders(), "comma-separated providers to check; use mock for deterministic local checks")
harnessesFlag := flag.String("harnesses", defaultHarnesses, "comma-separated harness names under internal/harness; agent runs the provider tool-call conformance test")
providersFlag := flag.String("providers", "anthropic,openai,gemini,groq,mistral,together,atlascloud", "comma-separated providers to check; use mock for deterministic local checks")
harnessesFlag := flag.String("harnesses", "universe,agent-flow,plan-delegate", "comma-separated harness names under internal/harness")
timeoutFlag := flag.Duration("timeout", 10*time.Minute, "timeout per provider/harness run")
requireConfiguredFlag := flag.Bool("require-configured", false, "fail when a selected live provider is missing an API key")
capabilitiesFlag := flag.Bool("capabilities", true, "print the registered provider capability matrix before running conformance")
@@ -100,16 +90,15 @@ func main() {
}
for _, harness := range harnesses {
phase := harnessPhase(harness)
fmt.Printf("\n==> %s / %s (%s)\n", provider, harness, phase)
fmt.Printf("\n==> %s / %s\n", provider, harness)
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
fmt.Printf("FAIL %s / %s (%s): %v\n", provider, harness, phase, err)
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
failed++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusFailed, Error: err.Error()})
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
continue
}
ran++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusPassed})
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
}
}
@@ -149,7 +138,6 @@ const (
type conformanceResult struct {
Provider string `json:"provider"`
Harness string `json:"harness,omitempty"`
Phase string `json:"phase,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
@@ -181,23 +169,17 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
var b strings.Builder
b.WriteString("# Provider conformance summary\n\n")
fmt.Fprintf(&b, "Passed: %d. Skipped providers: %d. Failed: %d.\n\n", summary.Passed, summary.Skipped, summary.Failed)
fmt.Fprintf(&b, "Providers: %s.\n\n", markdownList(summary.Providers))
fmt.Fprintf(&b, "Harnesses: %s.\n\n", markdownList(summary.Harnesses))
b.WriteString("## Capability matrix\n\n")
b.WriteString(capabilityMarkdown(summary.Capabilities))
b.WriteString("\n## Harness results\n\n")
b.WriteString("| Provider | Harness | Phase | Status | Detail |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
b.WriteString("| Provider | Harness | Status | Detail |\n")
b.WriteString("| --- | --- | --- | --- |\n")
for _, result := range summary.Results {
harness := result.Harness
if harness == "" {
harness = "—"
}
phase := result.Phase
if phase == "" {
phase = "—"
}
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", result.Provider, harness, markdownCell(phase), result.Status, markdownCell(result.Error))
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
@@ -212,24 +194,6 @@ func capabilityMarkdown(rows []ai.CapabilityRow) string {
return b.String()
}
func markdownList(values []string) string {
if len(values) == 0 {
return "—"
}
escaped := make([]string, 0, len(values))
for _, value := range values {
escaped = append(escaped, "`"+strings.ReplaceAll(value, "`", "\\`")+"`")
}
return strings.Join(escaped, ", ")
}
func harnessPhase(harness string) string {
if phase, ok := harnessPhases[harness]; ok {
return phase
}
return "harness"
}
func markdownCell(s string) string {
if s == "" {
return "—"
@@ -280,9 +244,6 @@ func validateSelection(providers, harnesses []string) error {
}
for _, harness := range harnesses {
if harness == "agent" {
continue
}
if strings.Contains(harness, string(os.PathSeparator)) || harness == "." || harness == ".." {
return fmt.Errorf("invalid harness name %q", harness)
}
@@ -315,15 +276,6 @@ func repoRoot() string {
}
}
func defaultProviders() string {
providers := make([]string, 0, len(providerEnv))
for provider := range providerEnv {
providers = append(providers, provider)
}
slices.Sort(providers)
return strings.Join(providers, ",")
}
func knownProviders() string {
providers := make([]string, 0, len(providerEnv)+1)
providers = append(providers, "mock")
@@ -371,10 +323,6 @@ func localRPCEnv(env []string) []string {
}
func runHarness(provider, harness string, timeout time.Duration) error {
if harness == "agent" {
return runAgentConformance(provider, timeout)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -410,28 +358,3 @@ func runHarness(provider, harness string, timeout time.Duration) error {
}
return nil
}
func runAgentConformance(provider string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
testProvider := provider
if provider == "mock" {
testProvider = "fake"
}
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProviderConformanceMatrix", "-count=1", "-v")
cmd.Dir = repoRoot()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = localRPCEnv(append(os.Environ(),
"GO_MICRO_AGENT_CONFORMANCE_LIVE=1",
"GO_MICRO_AGENT_CONFORMANCE_PROVIDERS="+testProvider,
))
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after %s", timeout)
}
return err
}
return nil
}
@@ -36,18 +36,6 @@ func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
}
}
func TestDefaultProvidersTracksLiveProviderSet(t *testing.T) {
got := defaultProviders()
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "mistral", "together", "atlascloud"} {
if !strings.Contains(got, want) {
t.Fatalf("defaultProviders() = %q, want %q", got, want)
}
}
if strings.Contains(got, "mock") {
t.Fatalf("defaultProviders() = %q, should not include mock in live scheduled defaults", got)
}
}
func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
rows := ai.CapabilityRows()
if len(rows) == 0 {
@@ -99,7 +87,7 @@ func TestWriteSummaryMarkdown(t *testing.T) {
summary := conformanceSummary{
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
Results: []conformanceResult{
{Provider: "mock", Harness: "agent-flow", Phase: harnessPhase("agent-flow"), Status: statusPassed},
{Provider: "mock", Harness: "agent-flow", Status: statusPassed},
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
},
Passed: 1,
@@ -117,11 +105,9 @@ func TestWriteSummaryMarkdown(t *testing.T) {
for _, want := range []string{
"# Provider conformance summary",
"Passed: 1. Skipped providers: 1. Failed: 0.",
"Providers: —.",
"Harnesses: —.",
"| mock | ✅ | — | — | — |",
"| mock | agent-flow | workflow event + tool call | passed | — |",
"| live | — | — | skipped | missing \\| key |",
"| mock | agent-flow | passed | — |",
"| live | — | skipped | missing \\| key |",
} {
if !strings.Contains(got, want) {
t.Fatalf("summary markdown = %q, want %q", got, want)
@@ -129,23 +115,6 @@ func TestWriteSummaryMarkdown(t *testing.T) {
}
}
func TestHarnessPhaseLabelsKnownHarnesses(t *testing.T) {
if got := harnessPhase("a2a-stream-fallback"); got != "streaming fallback + tool call" {
t.Fatalf("harnessPhase() = %q, want streaming fallback phase", got)
}
if got := harnessPhase("custom"); got != "harness" {
t.Fatalf("harnessPhase(custom) = %q, want fallback phase", got)
}
}
func TestMarkdownListEscapesBackticks(t *testing.T) {
got := markdownList([]string{"agent", "bad`name"})
want := "`agent`, `bad\\`name`"
if got != want {
t.Fatalf("markdownList() = %q, want %q", got, want)
}
}
func TestWriteSummaryJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "summary.json")
summary := conformanceSummary{
+5 -8
View File
@@ -38,10 +38,11 @@ import (
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
@@ -228,9 +229,8 @@ func runUniverse(provider string) int {
fmt.Println("broker connect:", err)
return 2
}
cl := harnessutil.Client(provider, reg)
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
st := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
// Services.
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
@@ -243,7 +243,7 @@ func runUniverse(provider string) int {
// The concierge agent: guardrails on, plus a tool-execution wrapper
// that counts calls — to prove the wrapper seam runs end-to-end.
var wrapped int64
conciergeOpts := []agent.Option{
concierge := agent.New(
agent.Name("concierge"),
agent.Services("notify"),
agent.Prompt("You notify buyers when their order is confirmed."),
@@ -257,9 +257,7 @@ func runUniverse(provider string) int {
}
}),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(st),
}
conciergeOpts = append(conciergeOpts, liveAgentOpts...)
concierge := agent.New(conciergeOpts...)
)
go concierge.Run()
defer concierge.Stop()
@@ -270,7 +268,6 @@ func runUniverse(provider string) int {
checkout := flow.New("checkout",
flow.Trigger("events.order.placed"),
flow.WithCheckpoint(flow.StoreCheckpoint(st, "checkout")),
flow.Timeout(harnessutil.LiveTimeout(provider)),
flow.Steps(
flow.Step{Name: "reserve", Run: flow.Call("inventory", "Inventory.Reserve")},
flow.Step{Name: "charge", Run: flow.Call("payment", "Payment.Charge")},
+3 -24
View File
@@ -4,35 +4,14 @@ This directory owns the no-secret reference scenario for the Go Micro
services → agents → workflows lifecycle. It is intentionally small and
scripted so CI can run it on every push without external services or model keys.
`run.sh` verifies four boundaries together:
`run.sh` verifies three boundaries together:
1. **Run**`micro run` remains available as the local development entry point.
2. **Chat**`micro chat` remains available as the interactive agent entry point.
3. **Inspect**`micro inspect agent <name>` and `micro inspect flow <name>`
remain available as the local run-history inspection step, with `micro flow
runs` preserving durable workflow history inspection.
4. **Deploy**`micro deploy --dry-run <target>` remains available as the
deployment-boundary checkpoint. The dry run resolves configured deploy targets
and services and prints the remote build/copy/systemd/health plan without
building binaries, opening SSH connections, running `rsync`, or touching
remote infrastructure.
After the CLI boundary smoke checks, the script runs the deterministic harnesses
that boot real services, agents, workflows, store-backed run history, plan/delegate,
and A2A with only the LLM mocked.
## Local and CI entry points
The default GitHub harness workflow runs this script on every push and pull
request after the 0→1 scaffold contract. Developers can run the same no-secret
contract locally with:
```sh
make harness
```
That target intentionally exercises both 0→1 scaffold variants, the 0→hero
scenario, the event-driven agent-flow harness, and mock provider conformance, so
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
outside CI as well. Live provider checks remain separate and gated by configured
API keys (`make provider-conformance` or the scheduled/manual CI job).
that boot real services, agents, workflows, store-backed run history, and A2A
with only the LLM mocked.
@@ -1,48 +0,0 @@
package zerotoheroci
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestZeroToHeroReferenceDocs(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"))
for _, want := range []string{
"make harness",
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
"./internal/harness/zero-to-hero-ci/run.sh",
"go run ./internal/harness/agent-flow",
"make provider-conformance-mock",
"internal/harness/plan-delegate",
"internal/harness/universe",
} {
if !strings.Contains(guide, want) {
t.Fatalf("0→hero guide missing %q", want)
}
}
readme := readFile(t, filepath.Join(root, "README.md"))
if !strings.Contains(readme, "internal/website/docs/guides/zero-to-hero.md") {
t.Fatal("README does not point to the canonical 0→hero guide")
}
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
if !strings.Contains(nav, "0→hero Reference") || !strings.Contains(nav, "/docs/guides/zero-to-hero.html") {
t.Fatal("website navigation does not expose the canonical 0→hero guide")
}
}
func readFile(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(data)
}
-1
View File
@@ -7,7 +7,6 @@ cd "$ROOT"
# Keep the developer inner-loop boundaries executable and discoverable in CI
# without secrets or long-running daemons.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider.
-3
View File
@@ -32,8 +32,6 @@ examples:
- title: Real-World Examples
url: /docs/examples/realworld/
guides:
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: The Agent Harness
url: /docs/guides/agent-harness.html
- title: Agents and Workflows
@@ -72,7 +70,6 @@ project:
- title: Server (optional)
url: /docs/server.html
search_order:
- /docs/guides/zero-to-hero.html
- /docs/getting-started.html
- /docs/mcp.html
- /docs/architecture.html
@@ -1,5 +1,4 @@
<a href="/docs/">Docs</a> &middot;
<a href="/blog/">Blog</a> &middot;
<a href="https://github.com/micro/go-micro">GitHub</a> &middot;
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a> &middot;
<a href="/support">Support</a>
@@ -1,5 +1,4 @@
<a href="/docs/">Docs</a>
<a href="/blog/">Blog</a>
<a href="/support"{% if page.nav_active == 'support' %} class="active"{% endif %}>Support</a>
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a>
<a href="https://github.com/micro/go-micro">GitHub</a>
+1 -1
View File
@@ -177,4 +177,4 @@ The future of microservices isn't fewer services. It's making them so easy to cr
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -102,4 +102,4 @@ micro chat --provider anthropic
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -144,4 +144,4 @@ The agent for your services answers. Not because you wired it up. Because the sy
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
+5 -5
View File
@@ -135,8 +135,8 @@ The store backend determines durability — file-backed by default, Postgres or
| **What** | Capability | Intelligence | Event orchestration |
| **Does** | Handles requests | Manages services | Reacts to events |
| **Knows** | Its endpoints | Its services' endpoints | Its trigger topic |
| **State** | Store | Store-backed memory | Checkpointed run history |
| **Create** | `micro.NewService("name")` | `micro.NewAgent("name")` | `micro.NewFlow("name")` |
| **State** | Store | Store (memory) | Stateless per event |
| **Create** | `micro.NewService()` | `micro.NewAgent()` | `micro.NewFlow()` |
| **Package** | `service/` | `agent/` | `flow/` |
They compose:
@@ -164,7 +164,7 @@ Or build an agent in Go:
```go
package main
import "go-micro.dev/v6"
import "go-micro.dev/v5"
func main() {
agent := micro.NewAgent("task-mgr",
@@ -176,8 +176,8 @@ func main() {
}
```
The agent implementation lives under `go-micro.dev/v6/agent`; most users create agents through the top-level `go-micro.dev/v6` API. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
The agent package is at `go-micro.dev/v5/agent`. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -165,4 +165,4 @@ Read the [Plan & Delegate guide](/docs/guides/plan-delegate) for the full refere
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*

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