Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345e85da9c |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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."
|
||||
@@ -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\`)."
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro) [](https://discord.gg/G8Gk5j3uXr)
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](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
|
||||
|
||||
<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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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++
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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]
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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> ·
|
||||
<a href="/blog/">Blog</a> ·
|
||||
<a href="https://github.com/micro/go-micro">GitHub</a> ·
|
||||
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a> ·
|
||||
<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>
|
||||
|
||||
@@ -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).*
|
||||
|
||||
@@ -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).*
|
||||
|
||||
@@ -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).*
|
||||
|
||||
@@ -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).*
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user