Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c70fa679c0 |
@@ -1,48 +0,0 @@
|
||||
name: Architecture Review
|
||||
|
||||
# Periodic high-altitude review of the whole framework and harness against the
|
||||
# North Star (internal/docs/THESIS.md) — part of the autonomous loop
|
||||
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). Where DevRel watches the public
|
||||
# story, the architect watches the system: API coherence, lifecycle gaps, and
|
||||
# whether recent increments are converging on the thesis or sprawling.
|
||||
#
|
||||
# The architect's OUTPUT is an assessment plus scoped follow-up issues that feed
|
||||
# the hourly increment loop — NOT large refactors. Breaking public-API and
|
||||
# architectural changes stay with the human (see CONTINUOUS_IMPROVEMENT.md).
|
||||
#
|
||||
# Opens a fresh issue and dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 8 */3 * *" # roughly every 3 days, 08:00 UTC (tunable)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: architecture-review
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open an architecture review issue and dispatch Codex
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
|
||||
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Architecture review #$RUN_NUMBER" \
|
||||
--body "Periodic architecture / harness review against the North Star in internal/docs/THESIS.md. Output: an assessment plus scoped follow-up issues for the increment loop.")
|
||||
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 for go-micro. Review the overall framework and harness against the North Star in internal/docs/THESIS.md (services → agents → workflows as one runtime) and the roadmap in ROADMAP.md. Assess: API coherence and consistency across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), gaps or missing pieces in the services → agents → workflows lifecycle, duplication or drift, and whether recent increments (scan recently merged PRs) are converging on the thesis or sprawling. Then: (A) post a concise architectural assessment as a comment on this issue (#$ISSUE_NUM) — strengths, the top risks/gaps, and a recommended direction for the next increments; (B) file concrete, scoped follow-up issues for the highest-value gaps so the hourly increment loop can pick them up — \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\` (each must be a single, self-contained, CI-verifiable chunk). Do NOT make breaking public-API or architectural changes yourself — your output is the assessment and the issues. A small, safe doc/comment correction may be a PR (\`git switch -c codex/architect-$ISSUE_NUM\` … \`gh pr create --base master --label codex …\` … \`gh pr merge --squash --auto --delete-branch\`). Do not use the make_pr tool (it is a no-op stub)."
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Auto-merge Codex PRs
|
||||
|
||||
# Part of the autonomous improvement loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
# Merges Codex's PRs once CI is green — no human involvement; CI (build, test,
|
||||
# golangci-lint, harnesses) is the only gate. Scoped to PRs that are BOTH
|
||||
# codex-labelled AND from a codex/* branch, so nothing else can auto-merge.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *" # sweep every 15 min
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: auto-merge-codex
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge green Codex PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr list --repo "$REPO" --label codex --state open \
|
||||
--json number,headRefName \
|
||||
--jq '.[] | select(.headRefName | startswith("codex/")) | .number' \
|
||||
| while read -r pr; do
|
||||
[ -z "$pr" ] && continue
|
||||
if gh pr checks "$pr" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "Checks green on #$pr — merging."
|
||||
gh pr merge "$pr" --repo "$REPO" --squash --delete-branch \
|
||||
|| echo "skip #$pr (not mergeable — conflicts?)"
|
||||
else
|
||||
echo "skip #$pr (checks pending/failing)"
|
||||
fi
|
||||
done
|
||||
@@ -5,11 +5,8 @@ name: Continuous Improvement
|
||||
#
|
||||
# A Claude Max subscription provides no API key for CI, so the loop is driven by
|
||||
# Codex rather than Claude Code: on a cadence this opens a fresh tracking issue and
|
||||
# posts an @codex instruction on it, and Codex runs one improvement increment, opens
|
||||
# a PR (git push + gh pr create — the make_pr tool is a no-op stub), and enables
|
||||
# GitHub auto-merge (gh pr merge --auto) so the PR lands once the required CI checks
|
||||
# pass. No separate merge sweep — branch protection + native auto-merge is the gate.
|
||||
# (See the per-issue rationale below.)
|
||||
# posts an @codex instruction on it, and Codex runs one improvement increment + opens
|
||||
# a PR. (See the per-issue rationale below.)
|
||||
#
|
||||
# Codex does NOT respond to comments authored by the github-actions bot, so the
|
||||
# dispatch is GATED on a CODEX_TRIGGER_TOKEN secret (a PAT for a user account Codex
|
||||
@@ -55,11 +52,11 @@ jobs:
|
||||
echo "a user account Codex follows) to activate the loop."
|
||||
exit 0
|
||||
fi
|
||||
# A unique issue per run → unique codex/ branch → no collisions.
|
||||
# A unique issue per run → Codex derives a unique branch → no collisions.
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Continuous improvement increment #$RUN_NUMBER" \
|
||||
--body "Autonomous continuous-improvement increment. North Star: internal/docs/THESIS.md; charter: internal/docs/CONTINUOUS_IMPROVEMENT.md. Tracker: #3024.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). Pick the single highest-value roadmap/issue/improvement-radar item that advances that thesis, implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body, including 'Closes #$ISSUE_NUM'>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). Pick the single highest-value roadmap/issue/improvement-radar item that advances that thesis, implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Run \`git push -u origin HEAD\` then \`gh pr create --base master --title \"<title>\" --body \"<body, including 'Closes #$ISSUE_NUM'>\"\`; the gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
name: DevRel Review
|
||||
|
||||
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
|
||||
# website (landing + docs), and blog — part of the autonomous loop
|
||||
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
|
||||
# code; this keeps the story coherent: docs/website aligned, README crisp, and
|
||||
# a steady supply of things worth blogging about.
|
||||
#
|
||||
# Like the increment loop it opens a fresh issue and dispatches Codex via
|
||||
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
|
||||
# SAFE factual-alignment and crispness fixes auto-merge; brand/positioning copy
|
||||
# and blog drafts are surfaced in the report for the human, never auto-merged.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: devrel-review
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open a DevRel review issue and dispatch Codex
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
|
||||
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "DevRel coherence review #$RUN_NUMBER" \
|
||||
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog. North Star: internal/docs/THESIS.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Act as DevRel for go-micro. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs / CHANGELOG.md); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work. Then do BOTH of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, and the blog ideas; (B) for SAFE factual-alignment and crispness fixes only (NOT brand/marketing/positioning rewrites), open one PR: \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. Leave brand/positioning copy and blog drafts for the human — describe them in the report, do NOT open auto-merging PRs for them. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
|
||||
@@ -2,9 +2,8 @@ name: Harness (E2E)
|
||||
|
||||
# Runs the end-to-end harnesses for agents, services, flows, and provider
|
||||
# conformance. The default job uses deterministic mock LLMs and needs no
|
||||
# secrets. A second job runs the same harnesses against the live provider set and
|
||||
# fails if any selected provider secret is missing, so scheduled conformance
|
||||
# cannot silently degrade.
|
||||
# secrets. A second job runs the same harnesses against any live providers
|
||||
# whose API key secrets are configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -27,17 +26,15 @@ jobs:
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: 0→1 scaffold contract
|
||||
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
|
||||
- name: Universe end-to-end (asserts; exits non-zero on failure)
|
||||
run: go run ./internal/harness/universe
|
||||
- name: Agent-flow harness
|
||||
run: go run ./internal/harness/agent-flow
|
||||
- name: 0→hero plan-delegate workflow harness
|
||||
- name: Plan-delegate harness
|
||||
run: go run ./internal/harness/plan-delegate
|
||||
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM conformance)
|
||||
name: Provider harnesses (live LLM, if keys present)
|
||||
runs-on: ubuntu-latest
|
||||
# Only on the daily schedule or a manual run — never automatically on
|
||||
# every push/PR, so changes don't quietly burn API credits. Trigger it
|
||||
@@ -50,7 +47,7 @@ jobs:
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
- name: Provider conformance against live models
|
||||
- name: Provider conformance against configured live models
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -59,19 +56,4 @@ jobs:
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: |
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-require-configured \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-summary-markdown provider-conformance-summary.md \
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
- name: Upload provider conformance summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: provider-conformance
|
||||
path: |
|
||||
provider-conformance-summary.json
|
||||
provider-conformance-summary.md
|
||||
provider-capabilities.md
|
||||
if-no-files-found: ignore
|
||||
run: go run ./internal/harness/provider-conformance
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- "**"
|
||||
jobs:
|
||||
|
||||
@@ -7,33 +7,9 @@ These instructions apply to the entire repository.
|
||||
When a Codex task makes repository changes and the requested outcome is a PR:
|
||||
|
||||
1. Keep the change focused on the assigned issue or prompt.
|
||||
2. Run the relevant verification commands and capture their results
|
||||
(`go build ./...`, `go test ./...`, `golangci-lint run ./...`).
|
||||
2. Run the relevant verification commands and capture their results.
|
||||
3. Check `git status --short` and review the diff before finishing.
|
||||
4. Create a uniquely-named branch under the `codex/` prefix (do not work on
|
||||
`master`, and do not use a generic name like `work`):
|
||||
4. Stage the intended files and create a local git commit on the current branch.
|
||||
5. Use the Codex `make_pr` tool to open the pull request with a concise title and a body that summarizes the change and testing.
|
||||
|
||||
```sh
|
||||
git switch -c codex/<issue-number>-<short-slug>
|
||||
```
|
||||
5. Stage the intended files and commit on that branch.
|
||||
6. Open the pull request yourself with the GitHub CLI, which is installed in the
|
||||
environment and whose `origin` points at this repository, then enable
|
||||
auto-merge so GitHub merges it once the required CI checks pass:
|
||||
|
||||
```sh
|
||||
git push -u origin HEAD
|
||||
gh pr create --base master --label codex \
|
||||
--title "<concise title>" \
|
||||
--body "<summary of the change and testing, including 'Closes #<issue>'>"
|
||||
gh pr merge --squash --auto --delete-branch
|
||||
```
|
||||
|
||||
The branch should start with `codex/` and the PR should carry the `codex`
|
||||
label. Auto-merge waits for the required status checks (build, tests,
|
||||
golangci-lint) — never merge a PR manually before CI is green.
|
||||
|
||||
Do not just say that a PR was opened, and do **not** rely on the `make_pr` tool:
|
||||
in this environment `make_pr` only records the title/body and never pushes a
|
||||
branch or creates a PR. The task is not complete until `gh pr create` has opened
|
||||
a real pull request and printed its URL.
|
||||
Do not just say that a PR was opened. If local changes exist, the task is not complete until the changes are committed and the `make_pr` tool has been called. A GitHub token in the shell environment is not a substitute for the Codex `make_pr` tool in this environment.
|
||||
|
||||
@@ -47,7 +47,7 @@ test-coverage:
|
||||
harness:
|
||||
go run ./internal/harness/universe
|
||||
go run ./internal/harness/agent-flow
|
||||
go run ./internal/harness/plan-delegate # 0→hero: services + agents + flow + plan/delegate
|
||||
go run ./internal/harness/plan-delegate
|
||||
|
||||
# Run the same harnesses against every configured live provider. Providers
|
||||
# without API keys are skipped; configured providers must pass.
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
Go Micro is an **agent harness** and service framework for Go.
|
||||
|
||||
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
|
||||
|
||||
Go Micro gives you 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.
|
||||
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it. Go Micro gives you that harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -255,9 +253,9 @@ Every endpoint is an AI-callable tool — and it can be a *paid* tool. Go Micro
|
||||
|
||||
```bash
|
||||
# Charge for tool calls at the MCP gateway (off unless you set a pay-to address)
|
||||
micro mcp serve --x402_pay_to 0xYourAddress --x402_network solana --x402_amount 10000
|
||||
micro mcp serve --x402-pay-to 0xYourAddress --x402-network solana --x402-amount 10000
|
||||
# Per-tool amounts via a config file
|
||||
micro mcp serve --x402_config x402.json
|
||||
micro mcp serve --x402-config x402.json
|
||||
```
|
||||
|
||||
See the [Payments (x402) guide](internal/website/docs/guides/x402-payments.md).
|
||||
|
||||
+4
-11
@@ -136,7 +136,7 @@ func (a *agentImpl) setup() {
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
|
||||
a.model = ai.New(a.opts.Provider, modelOpts...)
|
||||
if a.model != nil {
|
||||
if a.opts.TraceProvider != nil && a.model != nil {
|
||||
a.model = a.tracedModel(a.model)
|
||||
}
|
||||
|
||||
@@ -168,10 +168,6 @@ func (a *agentImpl) stateStore() store.Store {
|
||||
// Ask sends a message and returns the agent's response.
|
||||
// This is the programmatic API for direct use.
|
||||
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
|
||||
return a.ask(ctx, message, a.parentRunID)
|
||||
}
|
||||
|
||||
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -192,7 +188,7 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
a.runID = uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
ParentID: a.parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
@@ -232,21 +228,19 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
ParentID: a.parentRunID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat implements the proto AgentHandler interface for RPC.
|
||||
// @example {"message": "What tasks are overdue?"}
|
||||
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
|
||||
resp, err := a.ask(ctx, req.Message, req.ParentId)
|
||||
resp, err := a.Ask(ctx, req.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.Reply = resp.Reply
|
||||
rsp.Agent = resp.Agent
|
||||
rsp.RunId = resp.RunID
|
||||
rsp.ParentId = resp.ParentID
|
||||
for _, tc := range resp.ToolCalls {
|
||||
input, _ := json.Marshal(tc.Input)
|
||||
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
|
||||
@@ -267,7 +261,6 @@ func (a *agentImpl) Run() error {
|
||||
|
||||
a.server = server.NewServer(
|
||||
server.Name(a.opts.Name),
|
||||
server.Address(a.opts.Address),
|
||||
server.Registry(a.opts.Registry),
|
||||
server.Metadata(map[string]string{
|
||||
"type": "agent",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -38,51 +34,6 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponseIncludesRunIDs(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("chat-run"))
|
||||
var rsp pb.ChatResponse
|
||||
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello"}, &rsp); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if rsp.RunId == "" {
|
||||
t.Fatal("Chat response RunId is empty")
|
||||
}
|
||||
if rsp.Agent != "chat-run" {
|
||||
t.Errorf("Agent = %q, want chat-run", rsp.Agent)
|
||||
}
|
||||
if rsp.ParentId != "" {
|
||||
t.Errorf("ParentId = %q, want empty", rsp.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRequestParentIDPropagatesToResponse(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from model context")
|
||||
}
|
||||
if info.ParentID != "flow-run-123" {
|
||||
t.Fatalf("RunInfo.ParentID = %q, want flow-run-123", info.ParentID)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("chat-child"))
|
||||
var rsp pb.ChatResponse
|
||||
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello", ParentId: "flow-run-123"}, &rsp); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if rsp.ParentId != "flow-run-123" {
|
||||
t.Errorf("ParentId = %q, want flow-run-123", rsp.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
// Custom prompt
|
||||
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
|
||||
|
||||
@@ -108,7 +108,6 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
h = a.loopWrap(h)
|
||||
h = a.stepWrap(h)
|
||||
h = a.planWrap(h)
|
||||
h = contextWrap(h)
|
||||
h = a.traceTool(h)
|
||||
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
|
||||
h = a.opts.wrappers[i](h)
|
||||
@@ -116,21 +115,6 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// contextWrap stops tool execution promptly when the Ask context has
|
||||
// already been canceled or its deadline has expired. This keeps guardrail
|
||||
// bookkeeping and side-effecting tools from running after the caller has
|
||||
// abandoned the agent run.
|
||||
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errResult(call.ID, ctx.Err().Error())
|
||||
default:
|
||||
}
|
||||
return next(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
// baseHandler executes a tool call: a developer custom tool, the built-in
|
||||
// delegate, or an RPC to the service. It is the innermost handler.
|
||||
func (a *agentImpl) baseHandler() ai.ToolHandler {
|
||||
|
||||
+2
-11
@@ -39,7 +39,6 @@ type Options struct {
|
||||
Provider string
|
||||
Model string
|
||||
APIKey string
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Store store.Store
|
||||
@@ -136,13 +135,6 @@ func APIKey(k string) Option {
|
||||
return func(o *Options) { o.APIKey = k }
|
||||
}
|
||||
|
||||
// Address sets the network address for the agent's service endpoint.
|
||||
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
|
||||
// port and avoid advertising the default service address.
|
||||
func Address(addr string) Option {
|
||||
return func(o *Options) { o.Address = addr }
|
||||
}
|
||||
|
||||
// WithRegistry sets the service registry.
|
||||
func WithRegistry(r registry.Registry) Option {
|
||||
return func(o *Options) { o.Registry = r }
|
||||
@@ -250,9 +242,8 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider enables OpenTelemetry tracing for agent runs. The persisted
|
||||
// run timeline is recorded even when TraceProvider is nil; trace/span IDs are
|
||||
// added only when a provider is configured.
|
||||
// TraceProvider enables OpenTelemetry tracing for agent runs. When nil,
|
||||
// agent tracing and run timeline recording are disabled.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
|
||||
+32
-135
@@ -56,35 +56,18 @@ type RunEvent struct {
|
||||
|
||||
type Usage = ai.Usage
|
||||
|
||||
// RunListOptions controls how recorded agent run summaries are returned.
|
||||
// Zero values preserve the full deterministic run list.
|
||||
type RunListOptions struct {
|
||||
// Status, when set, keeps only runs with the matching status
|
||||
// (for example "running", "done", "error", or "refused").
|
||||
Status string
|
||||
// TraceID, when set, keeps only runs correlated with this trace id.
|
||||
// A prefix is accepted so operators can paste the shortened trace id
|
||||
// printed by `micro runs`.
|
||||
TraceID string
|
||||
// Limit, when positive, returns the most recently updated runs up to
|
||||
// the limit. Limited results are ordered newest first.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RunSummary is a compact index entry for a recorded agent run.
|
||||
type RunSummary struct {
|
||||
RunID string `json:"run_id"`
|
||||
Agent string `json:"agent"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
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"`
|
||||
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"`
|
||||
Events int `json:"events"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) tracer() trace.Tracer {
|
||||
@@ -92,23 +75,13 @@ 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()
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
|
||||
}
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
|
||||
start := time.Now()
|
||||
a.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()
|
||||
@@ -132,33 +105,14 @@ type tracedModel struct {
|
||||
|
||||
func (a *agentImpl) tracedModel(m ai.Model) ai.Model { return &tracedModel{Model: m, a: a} }
|
||||
func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.a.opts.TraceProvider == nil {
|
||||
return m.Model.Generate(ctx, req, opts...)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
provider := m.String()
|
||||
model := m.Options().Model
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attribute.String(AttrProvider, provider), attribute.String(AttrModel, model)))
|
||||
start := time.Now()
|
||||
|
||||
if m.a.opts.TraceProvider == nil {
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
usage := ai.Usage{}
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
}
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
m.a.recordRunEvent(e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
attribute.String(AttrProvider, provider),
|
||||
attribute.String(AttrModel, model),
|
||||
))
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
@@ -197,24 +151,13 @@ func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
|
||||
}
|
||||
|
||||
func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if a.opts.TraceProvider == nil {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(attribute.String(AttrToolName, call.Name), attribute.Bool(AttrDelegate, call.Name == toolDelegate)))
|
||||
start := time.Now()
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resultError(res)})
|
||||
return res
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
attribute.String(AttrToolName, call.Name),
|
||||
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
|
||||
))
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
@@ -257,7 +200,7 @@ func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if e.RunID == "" {
|
||||
if a.opts.TraceProvider == nil || e.RunID == "" {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(e)
|
||||
@@ -267,12 +210,6 @@ func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
|
||||
// ListRunSummaries returns a deterministic summary of recorded runs for agentName.
|
||||
func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
|
||||
return ListRunSummariesWithOptions(s, agentName, RunListOptions{})
|
||||
}
|
||||
|
||||
// ListRunSummariesWithOptions returns summaries of recorded runs for agentName,
|
||||
// optionally filtered by status and limited to the most recently updated runs.
|
||||
func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOptions) ([]RunSummary, error) {
|
||||
st := store.Scope(s, "agent", agentName)
|
||||
keys, err := st.List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
@@ -303,18 +240,16 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
|
||||
first := events[0]
|
||||
last := events[len(events)-1]
|
||||
summary := RunSummary{
|
||||
RunID: id,
|
||||
Agent: first.Agent,
|
||||
ParentID: first.ParentID,
|
||||
TraceID: first.TraceID,
|
||||
SpanID: first.SpanID,
|
||||
StartedAt: first.Time,
|
||||
UpdatedAt: last.Time,
|
||||
DurationMS: last.Time.Sub(first.Time).Milliseconds(),
|
||||
Events: len(events),
|
||||
Status: runStatus(events),
|
||||
LastKind: last.Kind,
|
||||
LastError: last.Error,
|
||||
RunID: id,
|
||||
Agent: first.Agent,
|
||||
ParentID: first.ParentID,
|
||||
TraceID: first.TraceID,
|
||||
SpanID: first.SpanID,
|
||||
StartedAt: first.Time,
|
||||
UpdatedAt: last.Time,
|
||||
Events: len(events),
|
||||
LastKind: last.Kind,
|
||||
LastError: last.Error,
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Agent != "" {
|
||||
@@ -333,49 +268,11 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
|
||||
summary.LastError = e.Error
|
||||
}
|
||||
}
|
||||
if opts.Status != "" && summary.Status != opts.Status {
|
||||
continue
|
||||
}
|
||||
if opts.TraceID != "" && !strings.HasPrefix(summary.TraceID, opts.TraceID) {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
|
||||
})
|
||||
if len(summaries) > opts.Limit {
|
||||
summaries = summaries[:opts.Limit]
|
||||
}
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func runStatus(events []RunEvent) string {
|
||||
if len(events) == 0 {
|
||||
return ""
|
||||
}
|
||||
status := "running"
|
||||
for _, e := range events {
|
||||
if e.Error != "" {
|
||||
status = "error"
|
||||
}
|
||||
if e.Refused != "" && status != "error" {
|
||||
status = "refused"
|
||||
}
|
||||
switch e.Kind {
|
||||
case "error":
|
||||
status = "error"
|
||||
case "done":
|
||||
if status == "running" {
|
||||
status = "done"
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
|
||||
st := store.Scope(s, "agent", agentName)
|
||||
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
|
||||
|
||||
+7
-93
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
@@ -47,33 +46,16 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
}
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
want := map[string]bool{spanNameRun: false, spanNameModelCall: false, spanNameToolCall: false}
|
||||
var runID string
|
||||
for _, s := range spans {
|
||||
if _, ok := want[s.Name()]; ok {
|
||||
want[s.Name()] = true
|
||||
}
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun {
|
||||
runID = attrs[AttrRunID]
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
|
||||
}
|
||||
}
|
||||
if runID == "" {
|
||||
t.Fatal("run span missing run id attribute")
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
|
||||
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -91,12 +73,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if summaries[0].LastKind != "done" {
|
||||
t.Fatalf("LastKind = %q, want done", summaries[0].LastKind)
|
||||
}
|
||||
if summaries[0].Status != "done" {
|
||||
t.Fatalf("Status = %q, want done", summaries[0].Status)
|
||||
}
|
||||
if summaries[0].DurationMS < 0 {
|
||||
t.Fatalf("DurationMS = %d, want non-negative", summaries[0].DurationMS)
|
||||
}
|
||||
if summaries[0].TraceID == "" || summaries[0].SpanID == "" {
|
||||
t.Fatalf("summary missing trace correlation: %#v", summaries[0])
|
||||
}
|
||||
@@ -109,15 +85,7 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
out[string(attr.Key)] = attr.Value.AsString()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
|
||||
func TestAgentOpenTelemetryNoopWhenUnconfigured(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
@@ -127,37 +95,11 @@ func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected run timeline without TraceProvider")
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("expected no run timeline without TraceProvider, got %v", keys)
|
||||
}
|
||||
summaries, err := ListRunSummaries(st, "runner-noop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("got %d summaries, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].Status != "done" || summaries[0].LastKind != "done" {
|
||||
t.Fatalf("unexpected summary without TraceProvider: %#v", summaries[0])
|
||||
}
|
||||
if summaries[0].TraceID != "" || summaries[0].SpanID != "" {
|
||||
t.Fatalf("unexpected trace correlation without TraceProvider: %#v", summaries[0])
|
||||
}
|
||||
events, err := LoadRunEvents(st, "runner-noop", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{"run": false, "model": false, "tool": false, "done": false}
|
||||
for _, e := range events {
|
||||
seen[e.Kind] = true
|
||||
if e.TraceID != "" || e.SpanID != "" {
|
||||
t.Fatalf("event has trace correlation without TraceProvider: %#v", e)
|
||||
}
|
||||
}
|
||||
for kind, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("missing %s event in timeline: %#v", kind, events)
|
||||
}
|
||||
if _, ok := a.(*agentImpl).model.(*tracedModel); ok {
|
||||
t.Fatal("model should not be wrapped when TraceProvider is nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,38 +164,10 @@ func TestListRunSummaries(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d summaries, want 2: %#v", len(got), got)
|
||||
}
|
||||
if got[0].RunID != "run-a" || got[0].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)) {
|
||||
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
|
||||
t.Fatalf("unexpected run-a summary: %#v", got[0])
|
||||
}
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "error" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "boom" {
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].LastKind != "error" || got[1].LastError != "boom" {
|
||||
t.Fatalf("unexpected run-b summary: %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
scoped := store.Scope(st, "agent", "runner")
|
||||
events := []RunEvent{
|
||||
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scoped.Write(&store.Record{Key: "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind, Value: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "error", TraceID: "abcdef", Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "error" {
|
||||
t.Fatalf("filtered summaries = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-70
@@ -2,7 +2,7 @@
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.21.12
|
||||
// source: agent/proto/agent.proto
|
||||
// source: proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
@@ -22,17 +22,15 @@ const (
|
||||
)
|
||||
|
||||
type ChatRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
// parent_id correlates this chat with the workflow or agent run that dispatched it.
|
||||
ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatRequest) Reset() {
|
||||
*x = ChatRequest{}
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[0]
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -44,7 +42,7 @@ func (x *ChatRequest) String() string {
|
||||
func (*ChatRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[0]
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -57,7 +55,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ChatRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetMessage() string {
|
||||
@@ -67,29 +65,18 @@ func (x *ChatRequest) GetMessage() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetParentId() string {
|
||||
if x != nil {
|
||||
return x.ParentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
|
||||
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
// run_id correlates this chat response with tool calls, traces, and run history.
|
||||
RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
|
||||
// parent_id is set when this response belongs to a delegated sub-agent run.
|
||||
ParentId string `protobuf:"bytes,5,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
|
||||
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatResponse) Reset() {
|
||||
*x = ChatResponse{}
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[1]
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -101,7 +88,7 @@ func (x *ChatResponse) String() string {
|
||||
func (*ChatResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[1]
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -114,7 +101,7 @@ func (x *ChatResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ChatResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{1}
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetReply() string {
|
||||
@@ -138,20 +125,6 @@ func (x *ChatResponse) GetToolCalls() []*ToolCall {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetRunId() string {
|
||||
if x != nil {
|
||||
return x.RunId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetParentId() string {
|
||||
if x != nil {
|
||||
return x.ParentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -164,7 +137,7 @@ type ToolCall struct {
|
||||
|
||||
func (x *ToolCall) Reset() {
|
||||
*x = ToolCall{}
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[2]
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -176,7 +149,7 @@ func (x *ToolCall) String() string {
|
||||
func (*ToolCall) ProtoMessage() {}
|
||||
|
||||
func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[2]
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -189,7 +162,7 @@ func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
|
||||
func (*ToolCall) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{2}
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetId() string {
|
||||
@@ -220,21 +193,18 @@ func (x *ToolCall) GetResult() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_agent_proto_agent_proto protoreflect.FileDescriptor
|
||||
var File_proto_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_agent_proto_agent_proto_rawDesc = "" +
|
||||
const file_proto_agent_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x17agent/proto/agent.proto\x12\x05agent\"D\n" +
|
||||
"\x11proto/agent.proto\x12\x05agent\"'\n" +
|
||||
"\vChatRequest\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" +
|
||||
"\tparent_id\x18\x02 \x01(\tR\bparentId\"\x9e\x01\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"j\n" +
|
||||
"\fChatResponse\x12\x14\n" +
|
||||
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
|
||||
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
|
||||
"\n" +
|
||||
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\x12\x15\n" +
|
||||
"\x06run_id\x18\x04 \x01(\tR\x05runId\x12\x1b\n" +
|
||||
"\tparent_id\x18\x05 \x01(\tR\bparentId\"\\\n" +
|
||||
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\"\\\n" +
|
||||
"\bToolCall\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
|
||||
@@ -244,24 +214,24 @@ const file_agent_proto_agent_proto_rawDesc = "" +
|
||||
"\x04Chat\x12\x12.agent.ChatRequest\x1a\x13.agent.ChatResponse\"\x00B\x0fZ\r./proto;agentb\x06proto3"
|
||||
|
||||
var (
|
||||
file_agent_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_agent_proto_agent_proto_rawDescData []byte
|
||||
file_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_proto_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_agent_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_agent_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)))
|
||||
func file_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_agent_proto_agent_proto_rawDescData
|
||||
return file_proto_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_agent_proto_agent_proto_goTypes = []any{
|
||||
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_proto_agent_proto_goTypes = []any{
|
||||
(*ChatRequest)(nil), // 0: agent.ChatRequest
|
||||
(*ChatResponse)(nil), // 1: agent.ChatResponse
|
||||
(*ToolCall)(nil), // 2: agent.ToolCall
|
||||
}
|
||||
var file_agent_proto_agent_proto_depIdxs = []int32{
|
||||
var file_proto_agent_proto_depIdxs = []int32{
|
||||
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
|
||||
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
|
||||
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
|
||||
@@ -272,26 +242,26 @@ var file_agent_proto_agent_proto_depIdxs = []int32{
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_agent_proto_agent_proto_init() }
|
||||
func file_agent_proto_agent_proto_init() {
|
||||
if File_agent_proto_agent_proto != nil {
|
||||
func init() { file_proto_agent_proto_init() }
|
||||
func file_proto_agent_proto_init() {
|
||||
if File_proto_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_agent_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_agent_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_agent_proto_agent_proto_msgTypes,
|
||||
GoTypes: file_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_proto_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_agent_proto_agent_proto = out.File
|
||||
file_agent_proto_agent_proto_goTypes = nil
|
||||
file_agent_proto_agent_proto_depIdxs = nil
|
||||
File_proto_agent_proto = out.File
|
||||
file_proto_agent_proto_goTypes = nil
|
||||
file_proto_agent_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: agent/proto/agent.proto
|
||||
// source: proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
|
||||
@@ -11,20 +11,12 @@ service Agent {
|
||||
|
||||
message ChatRequest {
|
||||
string message = 1;
|
||||
|
||||
// parent_id correlates this chat with the workflow or agent run that dispatched it.
|
||||
string parent_id = 2;
|
||||
}
|
||||
|
||||
message ChatResponse {
|
||||
string reply = 1;
|
||||
string agent = 2;
|
||||
repeated ToolCall tool_calls = 3;
|
||||
|
||||
// run_id correlates this chat response with tool calls, traces, and run history.
|
||||
string run_id = 4;
|
||||
// parent_id is set when this response belongs to a delegated sub-agent run.
|
||||
string parent_id = 5;
|
||||
}
|
||||
|
||||
message ToolCall {
|
||||
|
||||
@@ -3,7 +3,6 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -76,29 +75,3 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
|
||||
t.Fatalf("model attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{map[string]any{"task": "should not persist", "status": "pending"}},
|
||||
}})
|
||||
if !strings.Contains(res.Content, context.Canceled.Error()) {
|
||||
t.Fatalf("tool result = %q, want cancellation error", res.Content)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("cancel-tools"))
|
||||
if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if plan := a.loadPlan(); plan != "" {
|
||||
t.Fatalf("plan persisted after canceled tool context: %q", plan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,22 +188,6 @@ type Response struct {
|
||||
- `ToolCalls`: List of tools the model requested (if any)
|
||||
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
|
||||
|
||||
## Provider capability matrix
|
||||
|
||||
The CLI can print the provider capabilities registered in the current build:
|
||||
|
||||
```bash
|
||||
micro ai providers
|
||||
```
|
||||
|
||||
For automation and docs generation, emit the same matrix as stable JSON:
|
||||
|
||||
```bash
|
||||
micro ai providers --json
|
||||
```
|
||||
|
||||
It reports support from Go Micro's provider registry, so the matrix reflects the model, image, and video interfaces available to this binary rather than external provider marketing claims.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
@@ -161,7 +161,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
|
||||
@@ -2,7 +2,6 @@ package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -89,7 +88,7 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: atlascloud provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package atlascloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -89,8 +88,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package ai
|
||||
|
||||
import "sort"
|
||||
|
||||
// CapabilityRow is one deterministic row in a provider capability matrix.
|
||||
type CapabilityRow struct {
|
||||
// Provider is the registered provider name.
|
||||
Provider string `json:"provider"`
|
||||
Capabilities
|
||||
}
|
||||
|
||||
// Capabilities describes the AI interfaces a provider has registered.
|
||||
// It is intentionally based on package registration rather than external
|
||||
// provider marketing claims, so it reflects what this build can actually use.
|
||||
type Capabilities struct {
|
||||
// Model reports whether ai.New can construct a chat/text model provider.
|
||||
Model bool `json:"model"`
|
||||
// Image reports whether ai.NewImage can construct an image model provider.
|
||||
Image bool `json:"image"`
|
||||
// Video reports whether ai.NewVideo can construct a video model provider.
|
||||
Video bool `json:"video"`
|
||||
// Stream reports whether the provider has registered end-to-end token streaming.
|
||||
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
|
||||
// leave this false until their Stream implementation is usable.
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// ProviderCapabilities reports the capabilities registered for provider.
|
||||
func ProviderCapabilities(provider string) Capabilities {
|
||||
_, hasModel := providers[provider]
|
||||
_, hasImage := imageProviders[provider]
|
||||
_, hasVideo := videoProviders[provider]
|
||||
_, hasStream := streamProviders[provider]
|
||||
|
||||
return Capabilities{
|
||||
Model: hasModel,
|
||||
Image: hasImage,
|
||||
Video: hasVideo,
|
||||
Stream: hasStream,
|
||||
}
|
||||
}
|
||||
|
||||
// CapabilityMatrix returns a snapshot of all registered AI providers and the
|
||||
// interfaces they support. The returned map is a copy and can be modified by
|
||||
// callers without mutating the registry. Use CapabilityRows when rendering a
|
||||
// deterministic table or report.
|
||||
func CapabilityMatrix() map[string]Capabilities {
|
||||
names := map[string]struct{}{}
|
||||
for name := range providers {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range imageProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range videoProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range streamProviders {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
matrix := make(map[string]Capabilities, len(names))
|
||||
for name := range names {
|
||||
matrix[name] = ProviderCapabilities(name)
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
// CapabilityRows returns a deterministic capability support matrix for every
|
||||
// registered AI provider. It is the ordered form of CapabilityMatrix, intended
|
||||
// for CLIs, docs generators, and conformance reports that need stable output.
|
||||
func CapabilityRows() []CapabilityRow {
|
||||
names := RegisteredProviders("")
|
||||
rows := make([]CapabilityRow, 0, len(names))
|
||||
for _, name := range names {
|
||||
rows = append(rows, CapabilityRow{
|
||||
Provider: name,
|
||||
Capabilities: ProviderCapabilities(name),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// RegisterStream records that provider has a usable Stream implementation.
|
||||
// Providers should call this from init alongside Register once Stream returns
|
||||
// chunks instead of ErrStreamingUnsupported.
|
||||
func RegisterStream(provider string) {
|
||||
streamProviders[provider] = struct{}{}
|
||||
}
|
||||
|
||||
var streamProviders = make(map[string]struct{})
|
||||
|
||||
// RegisteredProviders returns the registered provider names in sorted order.
|
||||
// kind may be "model", "image", "video", "stream", or empty for the union of all
|
||||
// provider registries.
|
||||
func RegisteredProviders(kind string) []string {
|
||||
names := map[string]struct{}{}
|
||||
add := func(registry any) {
|
||||
switch r := registry.(type) {
|
||||
case map[string]NewFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]NewImageFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]NewVideoFunc:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
case map[string]struct{}:
|
||||
for name := range r {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "model":
|
||||
add(providers)
|
||||
case "stream":
|
||||
add(streamProviders)
|
||||
case "image":
|
||||
add(imageProviders)
|
||||
case "video":
|
||||
add(videoProviders)
|
||||
default:
|
||||
add(providers)
|
||||
add(imageProviders)
|
||||
add(videoProviders)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package ai_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
func TestRegisteredProviders(t *testing.T) {
|
||||
got := ai.RegisteredProviders("")
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("image")
|
||||
want = []string{"atlascloud", "openai"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(image) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("video")
|
||||
want = []string{"atlascloud"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(video) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityRows(t *testing.T) {
|
||||
got := ai.CapabilityRows()
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true}},
|
||||
{Provider: "together", Capabilities: ai.Capabilities{Model: true}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityMatrix(t *testing.T) {
|
||||
matrix := ai.CapabilityMatrix()
|
||||
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
|
||||
caps, ok := matrix[provider]
|
||||
if !ok {
|
||||
t.Fatalf("CapabilityMatrix missing %q", provider)
|
||||
}
|
||||
if !caps.Model {
|
||||
t.Fatalf("CapabilityMatrix(%s).Model = false, want true", provider)
|
||||
}
|
||||
}
|
||||
|
||||
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true}) {
|
||||
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true}) {
|
||||
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
|
||||
t.Fatalf("ProviderCapabilities(missing) = %#v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStream(t *testing.T) {
|
||||
ai.RegisterStream("test-stream")
|
||||
|
||||
if caps := ai.ProviderCapabilities("test-stream"); caps != (ai.Capabilities{Stream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(test-stream) = %#v", caps)
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"test-stream"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -135,7 +135,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for gemini provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -89,8 +88,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: groq provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for groq provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package groq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -41,8 +40,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: mistral provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for mistral provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package mistral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -41,8 +40,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-15
@@ -4,7 +4,6 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -113,17 +112,12 @@ const (
|
||||
// RunInfo describes the agent run a tool call belongs to. The agent
|
||||
// attaches it to the context passed to a ToolHandler, so a wrapper can
|
||||
// correlate calls within a run and across delegation without coupling to
|
||||
// the agent package. Flows also attach their name and current step so
|
||||
// tools and agents called from a workflow can be tied back to the
|
||||
// services → agents → workflows lifecycle that invoked them. Per-call
|
||||
// detail (tool name, id) is on the ToolCall; attempt counts are naturally
|
||||
// counted by the wrapper itself.
|
||||
// the agent package. Per-call detail (tool name, id) is on the ToolCall;
|
||||
// step and attempt counts are naturally counted by the wrapper itself.
|
||||
type RunInfo struct {
|
||||
RunID string // correlation id for this agent or flow run
|
||||
RunID string // correlation id for this agent run (one per Ask)
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
Flow string // the flow's name, when the call is part of a workflow
|
||||
Step string // the flow step currently executing, when known
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
@@ -139,12 +133,7 @@ func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// ErrStreamingUnsupported is returned by providers that implement the Model
|
||||
// interface but do not yet support token streaming. Use errors.Is so callers
|
||||
// can distinguish an unsupported capability from transient provider failures.
|
||||
var ErrStreamingUnsupported = errors.New("ai: streaming unsupported")
|
||||
|
||||
// Stream is the interface for streaming responses.
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: openai provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
|
||||
@@ -2,7 +2,6 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -89,8 +88,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type retryModel struct {
|
||||
generate func(context.Context, *Request, ...GenerateOption) (*Response, error)
|
||||
}
|
||||
|
||||
func (m retryModel) Init(...Option) error { return nil }
|
||||
func (m retryModel) Options() Options { return Options{} }
|
||||
func (m retryModel) Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
return m.generate(ctx, req, opts...)
|
||||
}
|
||||
func (m retryModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
|
||||
return nil, ErrStreamingUnsupported
|
||||
}
|
||||
func (m retryModel) String() string { return "retry-test" }
|
||||
|
||||
func TestGenerateWithRetryRetriesTransientErrors(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, errors.New("temporary provider outage")
|
||||
}
|
||||
return &Response{Reply: "ok"}, nil
|
||||
}}
|
||||
|
||||
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithRetry returned error: %v", err)
|
||||
}
|
||||
if resp.Reply != "ok" {
|
||||
t.Fatalf("response reply = %q, want ok", resp.Reply)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
cancel()
|
||||
return nil, errors.New("temporary provider outage")
|
||||
}}
|
||||
|
||||
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
MaxAttempts: 3,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
attempts := 0
|
||||
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}}
|
||||
|
||||
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
|
||||
Timeout: time.Millisecond,
|
||||
MaxAttempts: 2,
|
||||
Backoff: time.Millisecond,
|
||||
})
|
||||
var retryErr *RetryError
|
||||
if !errors.As(err, &retryErr) {
|
||||
t.Fatalf("error = %T %[1]v, want RetryError", err)
|
||||
}
|
||||
if retryErr.Attempts != 2 {
|
||||
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: together provider", ai.ErrStreamingUnsupported)
|
||||
return nil, fmt.Errorf("streaming not yet implemented for together provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package together
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -41,8 +40,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goai "go-micro.dev/v6/ai"
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "ai",
|
||||
Usage: "Inspect AI provider support",
|
||||
Subcommands: []*cli.Command{{
|
||||
Name: "providers",
|
||||
Usage: "Print the registered AI provider capability matrix",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "json",
|
||||
Usage: "Print the capability matrix as JSON",
|
||||
},
|
||||
},
|
||||
Action: providersAction,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func providersAction(c *cli.Context) error {
|
||||
rows := goai.CapabilityRows()
|
||||
if c.Bool("json") {
|
||||
return writeProviderJSON(c.App.Writer, rows)
|
||||
}
|
||||
writeProviderMatrix(c.App.Writer, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProviderJSON(w io.Writer, rows []goai.CapabilityRow) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(rows)
|
||||
}
|
||||
|
||||
func writeProviderMatrix(w io.Writer, rows []goai.CapabilityRow) {
|
||||
const check = "✓"
|
||||
fmt.Fprintln(w, "Provider Model Image Video")
|
||||
fmt.Fprintln(w, "-------- ----- ----- -----")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%-11s %-6s %-6s %-6s\n",
|
||||
row.Provider,
|
||||
mark(row.Model, check),
|
||||
mark(row.Image, check),
|
||||
mark(row.Video, check),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func mark(ok bool, value string) string {
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goai "go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestWriteProviderMatrix(t *testing.T) {
|
||||
rows := []goai.CapabilityRow{
|
||||
{Provider: "atlascloud", Capabilities: goai.Capabilities{Model: true, Image: true, Video: true}},
|
||||
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writeProviderMatrix(&out, rows)
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Provider Model Image Video",
|
||||
"atlascloud ✓ ✓ ✓",
|
||||
"openai ✓ ✓ -",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("matrix output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProviderJSON(t *testing.T) {
|
||||
rows := []goai.CapabilityRow{
|
||||
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeProviderJSON(&out, rows); err != nil {
|
||||
t.Fatalf("writeProviderJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
var got []goai.CapabilityRow
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("JSON output did not decode: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].Provider != "openai" || !got[0].Model || !got[0].Image || got[0].Video {
|
||||
t.Fatalf("decoded JSON = %#v, want openai model+image", got)
|
||||
}
|
||||
if !strings.HasSuffix(out.String(), "\n") {
|
||||
t.Fatalf("JSON output should end with newline: %q", out.String())
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ func init() {
|
||||
Name: "runs",
|
||||
Usage: "Show recorded agent runs",
|
||||
ArgsUsage: "[agent] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
@@ -28,7 +30,7 @@ func init() {
|
||||
if runID := c.Args().Get(1); runID != "" {
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
return printRunIndex(name, c.Bool("json"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -100,7 +102,9 @@ func init() {
|
||||
Name: "history",
|
||||
Usage: "Show an agent's stored conversation and run history",
|
||||
ArgsUsage: "[name] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
@@ -110,7 +114,7 @@ func init() {
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
if c.Bool("json") {
|
||||
return printRunIndex(name, runOptions(c), true)
|
||||
return printRunIndex(name, true)
|
||||
}
|
||||
// Read from the agent's scoped state store (database
|
||||
// "agent", table = name) — available whether or not the
|
||||
@@ -124,28 +128,15 @@ func init() {
|
||||
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
|
||||
}
|
||||
}
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
return printRunIndex(name, c.Bool("json"))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
|
||||
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
}
|
||||
}
|
||||
|
||||
func runOptions(c *cli.Context) goagent.RunListOptions {
|
||||
return goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
|
||||
}
|
||||
|
||||
func printRunIndex(name string, opts goagent.RunListOptions, asJSON bool) error {
|
||||
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
|
||||
func printRunIndex(name string, asJSON bool) error {
|
||||
runs, err := goagent.ListRunSummaries(store.DefaultStore, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,10 +155,7 @@ func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON b
|
||||
}
|
||||
fmt.Fprintln(w, " Runs:")
|
||||
for _, run := range runs {
|
||||
line := fmt.Sprintf(" %s status=%s events=%d duration=%s last=%s updated=%s", run.RunID, run.Status, run.Events, formatDurationMS(run.DurationMS), run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
if run.ParentID != "" {
|
||||
line += " parent=" + run.ParentID
|
||||
}
|
||||
line := fmt.Sprintf(" %s events=%d last=%s updated=%s", run.RunID, run.Events, run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
if run.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(run.TraceID)
|
||||
}
|
||||
@@ -211,9 +199,6 @@ func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent,
|
||||
if e.Tokens.TotalTokens > 0 {
|
||||
line += fmt.Sprintf(" tokens=%d", e.Tokens.TotalTokens)
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
line += " parent=" + e.ParentID
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(e.TraceID)
|
||||
}
|
||||
@@ -228,16 +213,6 @@ func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent,
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDurationMS(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "0ms"
|
||||
}
|
||||
if ms < 1000 {
|
||||
return fmt.Sprintf("%dms", ms)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs", float64(ms)/1000)
|
||||
}
|
||||
|
||||
func shortTraceID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
|
||||
@@ -13,15 +13,13 @@ import (
|
||||
|
||||
func TestWriteRunIndexJSON(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
StartedAt: time.Unix(0, 1),
|
||||
UpdatedAt: time.Unix(0, 2),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
TraceID: "1234567890abcdef",
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
StartedAt: time.Unix(0, 1),
|
||||
UpdatedAt: time.Unix(0, 2),
|
||||
Events: 2,
|
||||
LastKind: "tool",
|
||||
TraceID: "1234567890abcdef",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, true); err != nil {
|
||||
@@ -36,29 +34,6 @@ func TestWriteRunIndexJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := out.String()
|
||||
for _, want := range []string{"run-1", "status=done", "events=2", "duration=1.2s", "last=tool", "parent=parent-run"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
events := []goagent.RunEvent{{
|
||||
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
|
||||
@@ -71,7 +46,6 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
LatencyMS: 42,
|
||||
Tokens: ai.Usage{TotalTokens: 5},
|
||||
TraceID: "1234567890abcdef",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
|
||||
var human bytes.Buffer
|
||||
@@ -79,7 +53,7 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := human.String()
|
||||
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "parent=parent-run", "trace=1234567890ab"} {
|
||||
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "trace=1234567890ab"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
|
||||
@@ -19,45 +19,6 @@ import (
|
||||
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
|
||||
// it needs the Go toolchain and module access; it is skipped under `-short`.
|
||||
func TestZeroToOneContract(t *testing.T) {
|
||||
generated := generateService(t, "helloworld")
|
||||
|
||||
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
|
||||
if _, err := os.Stat(filepath.Join(generated.dir, rel)); err != nil {
|
||||
t.Fatalf("generated file %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
}
|
||||
|
||||
// TestZeroToOneNoMCPContract keeps the MCP opt-out path honest. Some services
|
||||
// intentionally run without the local MCP listener, but that variant must still
|
||||
// satisfy the same 0→1 contract: scaffold, tidy, and build without additional
|
||||
// toolchain dependencies.
|
||||
func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
generated := generateService(t, "worker", "--no-mcp")
|
||||
|
||||
main, err := os.ReadFile(filepath.Join(generated.dir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(main), "gateway/mcp") || strings.Contains(string(main), "WithMCP") {
|
||||
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
|
||||
}
|
||||
|
||||
generated.replaceModule(t)
|
||||
generated.build(t)
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
dir string
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
func generateService(t *testing.T, name string, args ...string) generatedService {
|
||||
t.Helper()
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("contract test shells out to the Go toolchain; skipped with -short")
|
||||
}
|
||||
@@ -78,44 +39,35 @@ func generateService(t *testing.T, name string, args ...string) generatedService
|
||||
defer os.Chdir(oldwd)
|
||||
|
||||
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
|
||||
set.Bool("no-mcp", false, "")
|
||||
set.Bool("proto", false, "")
|
||||
set.String("template", "", "")
|
||||
set.String("prompt", "", "")
|
||||
set.String("provider", "", "")
|
||||
set.String("api_key", "", "")
|
||||
if err := set.Parse(append(args, name)); err != nil {
|
||||
if err := set.Parse([]string{"helloworld"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := cli.NewContext(cli.NewApp(), set, nil)
|
||||
|
||||
if err := Run(ctx); err != nil {
|
||||
t.Fatalf("micro new %s %s: %v", strings.Join(args, " "), name, err)
|
||||
t.Fatalf("micro new helloworld: %v", err)
|
||||
}
|
||||
|
||||
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
|
||||
}
|
||||
serviceDir := filepath.Join(tmp, "helloworld")
|
||||
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
|
||||
if _, err := os.Stat(filepath.Join(serviceDir, rel)); err != nil {
|
||||
t.Fatalf("generated file %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) replaceModule(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
modPath := filepath.Join(g.dir, "go.mod")
|
||||
modPath := filepath.Join(serviceDir, "go.mod")
|
||||
mod, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) build(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = g.dir
|
||||
cmd.Dir = serviceDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -76,10 +75,6 @@ Examples:
|
||||
ArgsUsage: "[name]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print durable run history as JSON for automation"},
|
||||
&cli.BoolFlag{Name: "pending", Usage: "Only show runs that have not completed"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, failed)"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
&cli.StringFlag{Name: "stage", Usage: "Only show runs currently checkpointed at this stage"},
|
||||
},
|
||||
Action: flowRuns,
|
||||
},
|
||||
@@ -134,83 +129,19 @@ func flowRuns(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts, err := validateFlowRunOptions(flowRunOptions{Pending: c.Bool("pending"), Status: c.String("status"), Stage: c.String("stage"), Limit: c.Int("limit")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runs = filterFlowRuns(runs, opts)
|
||||
if len(runs) == 0 {
|
||||
if c.Bool("pending") {
|
||||
fmt.Printf(" No pending runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" No runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
return writeFlowRuns(os.Stdout, runs, c.Bool("json"))
|
||||
}
|
||||
|
||||
type flowRunOptions struct {
|
||||
Pending bool
|
||||
Status string
|
||||
Stage string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func validateFlowRunOptions(opts flowRunOptions) (flowRunOptions, error) {
|
||||
switch opts.Status {
|
||||
case "", "running", "done", "failed":
|
||||
default:
|
||||
return opts, fmt.Errorf("invalid run status %q: expected running, done, or failed", opts.Status)
|
||||
}
|
||||
if opts.Limit < 0 {
|
||||
return opts, fmt.Errorf("invalid limit %d: expected a non-negative value", opts.Limit)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func filterFlowRuns(runs []aiflow.Run, opts flowRunOptions) []aiflow.Run {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]aiflow.Run, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
if opts.Pending && run.Status == "done" {
|
||||
continue
|
||||
}
|
||||
if opts.Status != "" && run.Status != opts.Status {
|
||||
continue
|
||||
}
|
||||
if opts.Stage != "" && run.State.Stage != opts.Stage {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, run)
|
||||
}
|
||||
if opts.Limit > 0 && len(filtered) > opts.Limit {
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
if filtered[i].Updated.Equal(filtered[j].Updated) {
|
||||
return filtered[i].Started.Before(filtered[j].Started)
|
||||
}
|
||||
return filtered[i].Updated.Before(filtered[j].Updated)
|
||||
})
|
||||
start := len(filtered) - opts.Limit
|
||||
limited := append([]aiflow.Run(nil), filtered[start:]...)
|
||||
return limited
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func pendingFlowRuns(runs []aiflow.Run) []aiflow.Run {
|
||||
return filterFlowRuns(runs, flowRunOptions{Pending: true})
|
||||
}
|
||||
|
||||
func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
fmt.Fprintf(w, " %d run%s\n", len(runs), plural(len(runs)))
|
||||
for _, r := range runs {
|
||||
id := r.ID
|
||||
if len(id) > 8 {
|
||||
@@ -233,13 +164,6 @@ func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func flowFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{Name: "trigger", Usage: "Broker topic to subscribe to", EnvVars: []string{"MICRO_FLOW_TRIGGER"}},
|
||||
|
||||
@@ -29,7 +29,6 @@ func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"1 run",
|
||||
"12345678 failed stage=charge",
|
||||
"updated=2026-06-24T12:30:00Z",
|
||||
"- reserve done attempts=1",
|
||||
@@ -41,20 +40,6 @@ func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsInvalidStatus(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Status: "stuck"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid run status") {
|
||||
t.Fatalf("expected invalid status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsNegativeLimit(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Limit: -1})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid limit") {
|
||||
t.Fatalf("expected invalid limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFlowRunsJSON(t *testing.T) {
|
||||
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
|
||||
|
||||
@@ -70,87 +55,3 @@ func TestWriteFlowRunsJSON(t *testing.T) {
|
||||
t.Fatalf("decoded runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingFlowRunsFiltersCompletedRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := pendingFlowRuns(runs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pendingFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("pending runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStatus(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
{ID: "run-4", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Status: "failed"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-4" {
|
||||
t.Fatalf("failed runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStage(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed", State: aiflow.State{Stage: "reserve"}},
|
||||
{ID: "run-2", Status: "failed", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-3", Status: "running", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-4", Status: "done", State: aiflow.State{}},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Stage: "charge"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("charge-stage runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsLimitKeepsNewestRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("limited runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsCombinesPendingStatusAndLimit(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed"},
|
||||
{ID: "run-2", Status: "done"},
|
||||
{ID: "run-3", Status: "failed"},
|
||||
{ID: "run-4", Status: "running"},
|
||||
{ID: "run-5", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Pending: true, Status: "failed", Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-3" || got[1].ID != "run-5" {
|
||||
t.Fatalf("filtered runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"go-micro.dev/v6/cmd"
|
||||
|
||||
_ "go-micro.dev/v6/cmd/micro/a2a"
|
||||
_ "go-micro.dev/v6/cmd/micro/ai"
|
||||
_ "go-micro.dev/v6/cmd/micro/api"
|
||||
_ "go-micro.dev/v6/cmd/micro/chat"
|
||||
_ "go-micro.dev/v6/cmd/micro/cli"
|
||||
|
||||
+1
-11
@@ -2,7 +2,6 @@ package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
@@ -26,17 +25,11 @@ func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface
|
||||
func TestExecuteDispatchesToAgent(t *testing.T) {
|
||||
f := New("welcome", Agent("comms"), Prompt("welcome {{.Data}}"))
|
||||
|
||||
var svc, ep, parentID string
|
||||
var svc, ep string
|
||||
f.client = &fakeClient{
|
||||
Client: client.DefaultClient,
|
||||
callFn: func(req client.Request, rsp interface{}) error {
|
||||
svc, ep = req.Service(), req.Endpoint()
|
||||
reqFrame := req.Body().(*codecbytes.Frame)
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
|
||||
t.Fatalf("request body: %v", err)
|
||||
}
|
||||
parentID = body["parent_id"]
|
||||
frame := rsp.(*codecbytes.Frame)
|
||||
frame.Data = []byte(`{"reply":"welcomed bob","agent":"comms"}`)
|
||||
return nil
|
||||
@@ -50,9 +43,6 @@ func TestExecuteDispatchesToAgent(t *testing.T) {
|
||||
if svc != "comms" || ep != "Agent.Chat" {
|
||||
t.Errorf("dispatched to %s.%s, want comms.Agent.Chat", svc, ep)
|
||||
}
|
||||
if parentID == "" {
|
||||
t.Fatal("dispatch request parent_id is empty")
|
||||
}
|
||||
|
||||
results := f.Results()
|
||||
if len(results) != 1 {
|
||||
|
||||
+1
-5
@@ -205,9 +205,6 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: f.name})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
prompt := data
|
||||
@@ -280,8 +277,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
// callAgent hands the rendered prompt to a registered agent's Agent.Chat
|
||||
// endpoint over RPC and returns its reply.
|
||||
func (f *Flow) callAgent(ctx context.Context, name, message string) (string, error) {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
body, _ := json.Marshal(map[string]string{"message": message, "parent_id": info.RunID})
|
||||
body, _ := json.Marshal(map[string]string{"message": message})
|
||||
req := f.client.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
|
||||
var rsp codecbytes.Frame
|
||||
if err := f.client.Call(ctx, req, &rsp); err != nil {
|
||||
|
||||
+1
-12
@@ -1,10 +1,6 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// Options configures a Flow.
|
||||
type Options struct {
|
||||
@@ -44,8 +40,6 @@ type Options struct {
|
||||
// Checkpoint is the durability backend for stepped runs. Nil with
|
||||
// steps present means a store-backed default; set it to swap backends.
|
||||
Checkpoint Checkpoint
|
||||
// TraceProvider emits OpenTelemetry spans for stepped flow runs.
|
||||
TraceProvider trace.TracerProvider
|
||||
// DeleteOnSuccess removes a run's checkpoint when it completes
|
||||
// successfully. Failed runs are always retained. Default: retain all.
|
||||
DeleteOnSuccess bool
|
||||
@@ -138,8 +132,3 @@ func WithCheckpoint(c Checkpoint) Option {
|
||||
func DeleteOnSuccess() Option {
|
||||
return func(o *Options) { o.DeleteOnSuccess = true }
|
||||
}
|
||||
|
||||
// TraceProvider enables OpenTelemetry spans for stepped flow runs and steps.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const flowInstrumentationName = "go-micro.dev/v6/flow"
|
||||
|
||||
const (
|
||||
spanNameFlowRun = "flow.run"
|
||||
spanNameFlowStep = "flow.step"
|
||||
|
||||
AttrFlowRunID = "flow.run.id"
|
||||
AttrFlowParentID = "flow.run.parent_id"
|
||||
AttrFlowName = "flow.name"
|
||||
AttrFlowStepName = "flow.step.name"
|
||||
AttrFlowStatus = "flow.status"
|
||||
AttrFlowAttempts = "flow.step.attempts"
|
||||
AttrFlowLatencyMS = "flow.latency_ms"
|
||||
)
|
||||
|
||||
func (f *Flow) tracer() trace.Tracer {
|
||||
return f.opts.TraceProvider.Tracer(flowInstrumentationName)
|
||||
}
|
||||
|
||||
func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func(Run, error)) {
|
||||
if f.opts.TraceProvider == nil {
|
||||
return ctx, func(Run, error) {}
|
||||
}
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrFlowRunID, run.ID),
|
||||
attribute.String(AttrFlowParentID, run.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStatus, run.Status),
|
||||
))
|
||||
start := time.Now()
|
||||
return ctx, func(done Run, err error) {
|
||||
span.SetAttributes(
|
||||
attribute.String(AttrFlowStatus, done.Status),
|
||||
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, error) {
|
||||
if f.opts.TraceProvider == nil {
|
||||
return f.runStep(ctx, step, in)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
|
||||
attribute.String(AttrFlowRunID, info.RunID),
|
||||
attribute.String(AttrFlowParentID, info.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStepName, step.Name),
|
||||
))
|
||||
start := time.Now()
|
||||
out, attempts, err := f.runStep(ctx, step, in)
|
||||
span.SetAttributes(
|
||||
attribute.Int(AttrFlowAttempts, attempts),
|
||||
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
return out, attempts, err
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
|
||||
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
|
||||
in.Data = []byte("done")
|
||||
return in, nil
|
||||
}}
|
||||
f := New("observed", WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "observed")), TraceProvider(tp), Steps(step))
|
||||
ctx := withTestRunInfo(context.Background(), "agent-run-otel")
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
seen := map[string]bool{spanNameFlowRun: false, spanNameFlowStep: false}
|
||||
var runID string
|
||||
for _, span := range spans {
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
switch span.Name() {
|
||||
case spanNameFlowRun:
|
||||
seen[spanNameFlowRun] = true
|
||||
runID = attrs[AttrFlowRunID]
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("run span attributes = %#v", attrs)
|
||||
}
|
||||
case spanNameFlowStep:
|
||||
seen[spanNameFlowStep] = true
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("step span attributes = %#v", attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
|
||||
}
|
||||
}
|
||||
if runID == "" {
|
||||
t.Fatal("run span missing run id")
|
||||
}
|
||||
for _, span := range spans {
|
||||
if span.Name() != spanNameFlowStep {
|
||||
continue
|
||||
}
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
if attrs[AttrFlowRunID] != runID {
|
||||
t.Fatalf("step span run id = %q, want %q", attrs[AttrFlowRunID], runID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
out[string(attr.Key)] = attr.Value.AsString()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withTestRunInfo(ctx context.Context, runID string) context.Context {
|
||||
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
|
||||
}
|
||||
+24
-92
@@ -74,14 +74,13 @@ type StepRecord struct {
|
||||
// saves and loads. It is retained for success and failure unless the flow
|
||||
// opts into cleanup with DeleteOnSuccess.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Flow string `json:"flow"`
|
||||
State State `json:"state"`
|
||||
Steps []StepRecord `json:"steps"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
ID string `json:"id"`
|
||||
Flow string `json:"flow"`
|
||||
State State `json:"state"`
|
||||
Steps []StepRecord `json:"steps"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// Checkpoint persists and restores flow runs so a run survives a crash
|
||||
@@ -112,25 +111,16 @@ func StoreCheckpoint(s store.Store, scope string) Checkpoint {
|
||||
return &storeCheckpoint{store: store.Scope(s, "flow", scope)}
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Save(ctx context.Context, run Run) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
func (c *storeCheckpoint) Save(_ context.Context, run Run) error {
|
||||
run.Updated = time.Now()
|
||||
b, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.store.Write(&store.Record{Key: run.ID, Value: b})
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Load(ctx context.Context, runID string) (Run, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Run{}, false, err
|
||||
}
|
||||
func (c *storeCheckpoint) Load(_ context.Context, runID string) (Run, bool, error) {
|
||||
recs, err := c.store.Read(runID)
|
||||
if err == store.ErrNotFound || len(recs) == 0 {
|
||||
return Run{}, false, nil
|
||||
@@ -145,17 +135,11 @@ func (c *storeCheckpoint) Load(ctx context.Context, runID string) (Run, bool, er
|
||||
return run, true, nil
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) Delete(ctx context.Context, runID string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
func (c *storeCheckpoint) Delete(_ context.Context, runID string) error {
|
||||
return c.store.Delete(runID)
|
||||
}
|
||||
|
||||
func (c *storeCheckpoint) List(ctx context.Context) ([]Run, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := c.store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -241,8 +225,7 @@ func Dispatch(name string) StepFunc {
|
||||
if d := depsFrom(ctx); d != nil && d.client != nil {
|
||||
cl = d.client
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
body, _ := json.Marshal(map[string]string{"message": in.String(), "parent_id": info.RunID})
|
||||
body, _ := json.Marshal(map[string]string{"message": in.String()})
|
||||
req := cl.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
|
||||
var rsp codecbytes.Frame
|
||||
if err := cl.Call(ctx, req, &rsp); err != nil {
|
||||
@@ -307,20 +290,12 @@ func LLM(prompt string) StepFunc {
|
||||
|
||||
// startRun begins a fresh run of the flow's steps with the given input.
|
||||
func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
parentID := ""
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
parentID = info.RunID
|
||||
}
|
||||
run := Run{
|
||||
ID: uuid.New().String(),
|
||||
ParentID: parentID,
|
||||
Flow: f.name,
|
||||
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
|
||||
Status: "running",
|
||||
Started: time.Now(),
|
||||
ID: uuid.New().String(),
|
||||
Flow: f.name,
|
||||
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
|
||||
Status: "running",
|
||||
Started: time.Now(),
|
||||
}
|
||||
for _, s := range f.opts.Steps {
|
||||
run.Steps = append(run.Steps, StepRecord{Name: s.Name, Status: "pending"})
|
||||
@@ -331,9 +306,6 @@ func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
|
||||
// Resume continues a persisted run by id, picking up at the step it
|
||||
// stopped on. Completed runs are a no-op.
|
||||
func (f *Flow) Resume(ctx context.Context, runID string) error {
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.checkpoint == nil {
|
||||
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
|
||||
}
|
||||
@@ -396,10 +368,6 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
|
||||
start := stepIndex(steps, run.State.Stage)
|
||||
if start < 0 {
|
||||
@@ -414,22 +382,15 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
step := steps[i]
|
||||
run.State.Stage = step.Name
|
||||
run.Steps[i].Status = "in_progress"
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
f.save(ctx, run)
|
||||
|
||||
out, attempts, err := f.runStepSpan(ctx, step, run.State)
|
||||
out, attempts, err := f.runStep(ctx, step, run.State)
|
||||
run.Steps[i].Attempts = attempts
|
||||
if err != nil {
|
||||
spanErr = err
|
||||
run.Steps[i].Status = "failed"
|
||||
run.Steps[i].Error = err.Error()
|
||||
run.Status = "failed"
|
||||
if saveErr := f.save(ctx, run); saveErr != nil {
|
||||
spanErr = saveErr
|
||||
return run, fmt.Errorf("%w; additionally failed to checkpoint failed run: %v", err, saveErr)
|
||||
}
|
||||
f.save(ctx, run)
|
||||
f.record(resultFromRun(f.opts.TriggerTopic, run))
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s run %s failed at step %q: %v", f.name, run.ID, step.Name, err)
|
||||
return run, err
|
||||
@@ -443,22 +404,13 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
} else {
|
||||
run.State.Stage = ""
|
||||
}
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
f.save(ctx, run)
|
||||
}
|
||||
|
||||
run.Status = "done"
|
||||
if err := f.save(ctx, run); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
f.save(ctx, run)
|
||||
if f.opts.DeleteOnSuccess && f.checkpoint != nil {
|
||||
if err := f.checkpoint.Delete(ctx, run.ID); err != nil {
|
||||
spanErr = err
|
||||
return run, err
|
||||
}
|
||||
_ = f.checkpoint.Delete(ctx, run.ID)
|
||||
}
|
||||
f.record(resultFromRun(f.opts.TriggerTopic, run))
|
||||
f.log.Logf(logger.InfoLevel, "Flow %s run %s completed (%d steps)", f.name, run.ID, len(steps))
|
||||
@@ -484,10 +436,6 @@ func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, er
|
||||
if err := ctx.Err(); err != nil {
|
||||
return in, attempt - 1, err
|
||||
}
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
info.Step = step.Name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
}
|
||||
out, err := step.Run(ctx, in)
|
||||
if err == nil {
|
||||
return out, attempt, nil
|
||||
@@ -504,29 +452,13 @@ func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, er
|
||||
return in, retries + 1, lastErr
|
||||
}
|
||||
|
||||
func (f *Flow) save(ctx context.Context, run Run) error {
|
||||
func (f *Flow) save(ctx context.Context, run Run) {
|
||||
if f.checkpoint == nil {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
if err := f.checkpoint.Save(ctx, run); err != nil {
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s checkpoint save: %v", f.name, err)
|
||||
return fmt.Errorf("flow %s checkpoint save: %w", f.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSteps(steps []Step) error {
|
||||
seen := make(map[string]struct{}, len(steps))
|
||||
for i, step := range steps {
|
||||
if step.Name == "" {
|
||||
return fmt.Errorf("flow: step %d has an empty name", i)
|
||||
}
|
||||
if _, ok := seen[step.Name]; ok {
|
||||
return fmt.Errorf("flow: duplicate step name %q", step.Name)
|
||||
}
|
||||
seen[step.Name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepIndex(steps []Step, name string) int {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
@@ -87,51 +86,6 @@ func TestFlowCheckpointResume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
var got ai.RunInfo
|
||||
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
|
||||
var ok bool
|
||||
got, ok = ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from step context")
|
||||
}
|
||||
in.Data = []byte("ok")
|
||||
return in, nil
|
||||
}}
|
||||
|
||||
mem := store.NewMemoryStore()
|
||||
f := New("correlated",
|
||||
WithCheckpoint(StoreCheckpoint(mem, "correlated")),
|
||||
Steps(step),
|
||||
)
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "agent-run-1", Agent: "planner"})
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got.Agent != "correlated" {
|
||||
t.Fatalf("RunInfo.Agent = %q, want correlated", got.Agent)
|
||||
}
|
||||
if got.RunID == "" {
|
||||
t.Fatal("RunInfo.RunID is empty")
|
||||
}
|
||||
if got.Flow != "correlated" {
|
||||
t.Fatalf("RunInfo.Flow = %q, want correlated", got.Flow)
|
||||
}
|
||||
if got.ParentID != "agent-run-1" {
|
||||
t.Fatalf("RunInfo.ParentID = %q, want agent-run-1", got.ParentID)
|
||||
}
|
||||
if got.Step != "inspect" {
|
||||
t.Fatalf("RunInfo.Step = %q, want inspect", got.Step)
|
||||
}
|
||||
runs, err := StoreCheckpoint(mem, "correlated").List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ParentID != "agent-run-1" {
|
||||
t.Fatalf("persisted parent id = %+v, want agent-run-1", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowResumePendingResumesOldestRunsUntilFailure(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
ctx := context.Background()
|
||||
@@ -336,41 +290,6 @@ func TestFlowStepRetryStopsOnCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepNamesMustBeUnique(t *testing.T) {
|
||||
step := Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
|
||||
return in, nil
|
||||
}}
|
||||
f := New("duplicate-steps",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "duplicate-steps")),
|
||||
Steps(step, step),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate step names to fail validation")
|
||||
}
|
||||
if got, want := err.Error(), `flow: duplicate step name "work"`; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepNamesMustBeNonEmpty(t *testing.T) {
|
||||
f := New("empty-step-name",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "empty-step-name")),
|
||||
Steps(Step{Name: "", Run: func(_ context.Context, in State) (State, error) {
|
||||
return in, nil
|
||||
}}),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an empty step name to fail validation")
|
||||
}
|
||||
if got, want := err.Error(), "flow: step 0 has an empty name"; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A step with no Run function is reported as a configuration error rather
|
||||
// than panicking the run.
|
||||
func TestFlowStepNilRun(t *testing.T) {
|
||||
@@ -414,80 +333,6 @@ func TestStoreCheckpointListReturnsRunsInStartedOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCheckpointHonorsCanceledContext(t *testing.T) {
|
||||
cp := StoreCheckpoint(store.NewMemoryStore(), "canceled")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
run := Run{ID: "canceled", Started: time.Now()}
|
||||
|
||||
if err := cp.Save(ctx, run); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Save error = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, ok, err := cp.Load(ctx, run.ID); !errors.Is(err, context.Canceled) || ok {
|
||||
t.Fatalf("Load ok, error = %v, %v; want false, context.Canceled", ok, err)
|
||||
}
|
||||
if err := cp.Delete(ctx, run.ID); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Delete error = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := cp.List(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("List error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingCheckpoint struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c failingCheckpoint) Save(context.Context, Run) error { return c.err }
|
||||
func (c failingCheckpoint) Load(context.Context, string) (Run, bool, error) {
|
||||
return Run{}, false, c.err
|
||||
}
|
||||
func (c failingCheckpoint) Delete(context.Context, string) error { return c.err }
|
||||
func (c failingCheckpoint) List(context.Context) ([]Run, error) { return nil, c.err }
|
||||
|
||||
func TestFlowCheckpointSaveFailureStopsRun(t *testing.T) {
|
||||
checkpointErr := errors.New("checkpoint unavailable")
|
||||
var ran bool
|
||||
f := New("checkpoint-fails",
|
||||
WithCheckpoint(failingCheckpoint{err: checkpointErr}),
|
||||
Steps(Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
|
||||
ran = true
|
||||
return in, nil
|
||||
}}),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "start")
|
||||
if !errors.Is(err, checkpointErr) {
|
||||
t.Fatalf("Execute error = %v, want checkpoint error", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("step ran even though the in-progress checkpoint failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowDeleteOnSuccessFailureIsReturned(t *testing.T) {
|
||||
checkpointErr := errors.New("delete unavailable")
|
||||
cp := &deleteFailCheckpoint{Checkpoint: StoreCheckpoint(store.NewMemoryStore(), "delete-fails"), err: checkpointErr}
|
||||
f := New("delete-fails",
|
||||
WithCheckpoint(cp),
|
||||
DeleteOnSuccess(),
|
||||
Steps(appendStep("work")),
|
||||
)
|
||||
|
||||
err := f.Execute(context.Background(), "")
|
||||
if !errors.Is(err, checkpointErr) {
|
||||
t.Fatalf("Execute error = %v, want delete error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type deleteFailCheckpoint struct {
|
||||
Checkpoint
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *deleteFailCheckpoint) Delete(context.Context, string) error { return c.err }
|
||||
|
||||
func TestStateSetScan(t *testing.T) {
|
||||
var s State
|
||||
type payload struct {
|
||||
|
||||
+20
-83
@@ -18,11 +18,10 @@
|
||||
// BaseURL: "https://agents.example.com",
|
||||
// })
|
||||
//
|
||||
// Scope of this version: the JSON-RPC binding — `message/send`
|
||||
// (returns a completed Task), `message/stream` (SSE with the completed
|
||||
// Task event), `tasks/get`, and Agent Card discovery. Multi-turn
|
||||
// `input-required`, `tasks/resubscribe`, and push notifications are
|
||||
// advertised as unsupported and are follow-ups.
|
||||
// Scope of this version: the synchronous JSON-RPC binding — `message/send`
|
||||
// (returns a completed Task), `tasks/get`, and Agent Card discovery.
|
||||
// Streaming (`message/stream`), multi-turn `input-required`, and push
|
||||
// notifications are advertised as unsupported and are follow-ups.
|
||||
package a2a
|
||||
|
||||
import (
|
||||
@@ -315,7 +314,7 @@ func Card(name, url, description string, services []string) AgentCard {
|
||||
URL: url,
|
||||
Version: "1.0.0",
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: Capabilities{Streaming: true, PushNotifications: false},
|
||||
Capabilities: Capabilities{Streaming: false, PushNotifications: false},
|
||||
// The agent converses over a single Chat endpoint; advertise that
|
||||
// as one skill, tagged with the services it manages.
|
||||
DefaultInputModes: []string{"text/plain"},
|
||||
@@ -416,16 +415,14 @@ func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke
|
||||
|
||||
switch req.Method {
|
||||
case "message/send":
|
||||
d.send(requestContext(r.Context()), w, req, invoke)
|
||||
case "message/stream":
|
||||
d.stream(requestContext(r.Context()), w, req, invoke)
|
||||
d.send(w, req, invoke)
|
||||
case "tasks/get":
|
||||
d.get(w, req)
|
||||
case "tasks/cancel":
|
||||
// v1 tasks complete synchronously, so they're already terminal.
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errNotCancelable, Message: "task is not cancelable"})
|
||||
case "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "resubscribe is not supported"})
|
||||
case "message/stream", "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "streaming is not supported"})
|
||||
default:
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "method not found: " + req.Method})
|
||||
}
|
||||
@@ -435,42 +432,19 @@ type sendParams struct {
|
||||
Message Message `json:"message"`
|
||||
}
|
||||
|
||||
func (d *dispatcher) send(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
task, e := d.run(ctx, req.Params, invoke)
|
||||
if e != nil {
|
||||
writeRPC(w, req.ID, nil, e)
|
||||
return
|
||||
}
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
task, e := d.run(ctx, req.Params, invoke)
|
||||
if e != nil {
|
||||
writeRPC(w, req.ID, nil, e)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(sseWriter{w: w}).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Invoke) (*Task, *rpcError) {
|
||||
func (d *dispatcher) send(w http.ResponseWriter, req rpcRequest, invoke Invoke) {
|
||||
var p sendParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid params"}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
return
|
||||
}
|
||||
text := textOf(p.Message.Parts)
|
||||
if text == "" {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"}
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"})
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := invoke(ctx, text)
|
||||
reply, err := invoke(r2ctx(), text)
|
||||
contextID := p.Message.ContextID
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
@@ -490,7 +464,7 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
|
||||
task.Artifacts = []Artifact{textArtifact(reply)}
|
||||
}
|
||||
d.store(task)
|
||||
return task, nil
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
type getParams struct {
|
||||
@@ -568,47 +542,6 @@ func textArtifact(text string) Artifact {
|
||||
}
|
||||
}
|
||||
|
||||
// requestContext carries request cancellation and deadlines into the downstream
|
||||
// agent call without leaking HTTP transport context values into the go-micro
|
||||
// client stack.
|
||||
func requestContext(parent context.Context) context.Context {
|
||||
if err := parent.Err(); err != nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
ctx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
if deadline, ok := parent.Deadline(); ok {
|
||||
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
go func() {
|
||||
<-parent.Done()
|
||||
cancel()
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type sseWriter struct {
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
func (s sseWriter) Write(p []byte) (int, error) {
|
||||
if _, err := s.w.Write([]byte("data: ")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := s.w.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if _, err := s.w.Write([]byte("\n")); err != nil {
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -621,3 +554,7 @@ func writeRPC(w http.ResponseWriter, id json.RawMessage, result any, e *rpcError
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: e})
|
||||
}
|
||||
|
||||
// r2ctx returns a background context for the agent call. (Kept as a seam
|
||||
// so request-scoped context/deadlines can be threaded later.)
|
||||
func r2ctx() context.Context { return context.Background() }
|
||||
|
||||
+2
-77
@@ -4,10 +4,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +44,6 @@ func newGatewayWithAgent(t *testing.T) (*httptest.Server, func()) {
|
||||
|
||||
srv := server.NewServer(
|
||||
server.Name("echo"),
|
||||
server.Address("127.0.0.1:0"),
|
||||
server.Registry(reg),
|
||||
server.Metadata(map[string]string{"type": "agent", "services": ""}),
|
||||
)
|
||||
@@ -112,78 +109,6 @@ func TestMessageSendAndGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSendUsesRequestContext(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{
|
||||
"jsonrpc":"2.0","id":1,"method":"message/send",
|
||||
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
|
||||
"parts":[{"kind":"text","text":"ping"}]}}}`))
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
d.serve(rr, req, func(ctx context.Context, text string) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "unexpected success", nil
|
||||
})
|
||||
|
||||
var resp struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Result().Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", resp.Error)
|
||||
}
|
||||
if resp.Result.Status.State != stateFailed {
|
||||
t.Fatalf("task state = %q, want failed", resp.Result.Status.State)
|
||||
}
|
||||
if len(resp.Result.Artifacts) != 1 || textOf(resp.Result.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("artifact = %+v, want context cancellation", resp.Result.Artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStream(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
resp, err := http.Post(ts.URL+"/agents/echo", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
|
||||
var line string
|
||||
if _, err := fmt.Fscan(resp.Body, &line); err != nil {
|
||||
t.Fatalf("read event prefix: %v", err)
|
||||
}
|
||||
if line != "data:" {
|
||||
t.Fatalf("event prefix = %q, want data:", line)
|
||||
}
|
||||
var out struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
if out.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", out.Error)
|
||||
}
|
||||
if out.Result.Status.State != stateCompleted || len(out.Result.Artifacts) != 1 || textOf(out.Result.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("streamed task = %+v", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownMethod(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
@@ -191,9 +116,9 @@ func TestUnknownMethod(t *testing.T) {
|
||||
var resp struct {
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"tasks/resubscribe","params":{}}`, &resp)
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{}}`, &resp)
|
||||
if resp.Error == nil || resp.Error.Code != errMethodNotFound {
|
||||
t.Errorf("expected method-not-found for resubscribe, got %+v", resp.Error)
|
||||
t.Errorf("expected method-not-found for streaming, got %+v", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,80 +69,11 @@ Grounded in real signal, never speculative rewrites. Each cycle draws from:
|
||||
recurring jobs expire after 7 days, so it is **not** a durable scheduler.
|
||||
- **GitHub Actions (durable)** — a scheduled workflow that runs the loop
|
||||
independently of any session. This is the real backbone; it opens a fresh
|
||||
tracking issue for each increment and dispatches Codex there. It needs a
|
||||
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
tracking issue for each increment and dispatches Codex there, so every run gets
|
||||
a unique Codex-derived branch and a PR that closes its tracking issue. It needs
|
||||
a `CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
without that secret the workflow deliberately no-ops to avoid ignored bot
|
||||
comments. See `.github/workflows/continuous-improvement.yml` and the mechanics
|
||||
below.
|
||||
|
||||
## How the durable loop works (mechanics)
|
||||
|
||||
Hard-won wiring — change any one piece and the loop silently stops producing
|
||||
merged PRs. Each scheduled run:
|
||||
|
||||
1. **Opens a fresh issue per increment** (`Continuous improvement increment #N`)
|
||||
and posts the `@codex` instruction on it. *Why a fresh issue:* Codex derives
|
||||
its branch name from the triggering issue's context, so re-using one tracker
|
||||
issue collapses every run onto one branch name and only the first PR opens —
|
||||
the rest collide and silently fail.
|
||||
2. **Posts as a user, not the Actions bot.** Codex ignores `@codex` comments
|
||||
authored by `github-actions[bot]`, so the dispatch uses `CODEX_TRIGGER_TOKEN`
|
||||
(a PAT for a user account Codex follows). No token → the step no-ops.
|
||||
3. **Codex opens the PR itself with `gh` — never `make_pr`.** In the Codex Cloud
|
||||
sandbox the `make_pr` tool is a **no-op stub**: it records the PR title/body
|
||||
for the manual "Create PR" button and never pushes a branch or calls the API.
|
||||
So the dispatch and [`AGENTS.md`](../../AGENTS.md) tell Codex to do it by hand:
|
||||
|
||||
```sh
|
||||
git switch -c codex/increment-<issue> # unique branch, codex/ prefix
|
||||
git push -u origin codex/increment-<issue>
|
||||
gh pr create --base master --label codex --title "…" --body "… Closes #<issue>"
|
||||
gh pr merge --squash --auto --delete-branch
|
||||
```
|
||||
|
||||
This requires the Codex setup script to install `gh` and run `gh auth
|
||||
setup-git` (so `git push` is authenticated) with a write-scoped token.
|
||||
4. **Merges via GitHub native auto-merge, gated by branch protection.** `master`
|
||||
requires the CI status checks (build, tests, golangci-lint) and **0 approving
|
||||
reviews**. `gh pr merge --auto` enables auto-merge; GitHub lands the PR the
|
||||
moment checks pass and deletes the branch. `Closes #<issue>` auto-closes the
|
||||
tracking issue. There is **no merge sweep workflow** — branch protection is
|
||||
the gate.
|
||||
|
||||
### Do-not-break list
|
||||
|
||||
- **Don't re-add required approvals** to `master` — it blocks every autonomous
|
||||
merge. The intended gate is **green CI only**.
|
||||
- **Don't point the dispatch at one standing tracker issue** — one issue per run.
|
||||
- **Don't tell Codex to use `make_pr`** (or imply a token "isn't a substitute"):
|
||||
it cannot open a PR. `gh` is the only path.
|
||||
- **Don't manually re-implement a Codex increment during the summary→PR lag**
|
||||
(Codex posts an optimistic "opened a PR" comment ~30–45 min before the PR
|
||||
actually appears). Re-doing it creates duplicate PRs and stale branches that
|
||||
then block the next run. Wait for the PR, or let it ride.
|
||||
|
||||
## Overseer passes (DevRel + Architect)
|
||||
|
||||
The hourly loop ships increments; two periodic passes keep the *whole* heading in
|
||||
the right direction. Both use the same mechanism (fresh issue → `@codex` →
|
||||
output) but produce direction and coherence, not just code.
|
||||
|
||||
- **DevRel — daily** (`.github/workflows/devrel-review.yml`). Audits the public
|
||||
surface (README, website landing + docs, blog) for coherence with the North
|
||||
Star, README crispness, and blog-worthy material. **Autonomy boundary:** safe
|
||||
factual-alignment and crispness fixes auto-merge like any increment;
|
||||
brand/positioning copy and blog drafts are *surfaced in a report* for the
|
||||
human, never auto-merged.
|
||||
- **Architect — every few days** (`.github/workflows/architecture-review.yml`).
|
||||
Reviews the framework/harness against the thesis: API coherence, lifecycle
|
||||
gaps, drift/sprawl. **Its output is an assessment plus scoped follow-up
|
||||
issues** that feed the hourly increment loop — it does **not** make breaking or
|
||||
architectural changes itself (those stay with the human).
|
||||
|
||||
Together they close the loop: the architect decides *what* should change and files
|
||||
issues, the increment loop *builds* them, and DevRel keeps the public story
|
||||
honest. Cadence is tunable in each workflow's `cron`. Codex is serial, so these
|
||||
passes queue behind any in-flight increment rather than running concurrently.
|
||||
comments. See `.github/workflows/continuous-improvement.yml`.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
|
||||
@@ -207,19 +207,18 @@ func main() {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
ws := service.New(service.Name("workspace"), service.Registry(reg), service.Client(cl))
|
||||
ws.Handle(wsSvc)
|
||||
go ws.Run()
|
||||
|
||||
ntSvc := new(NotifyService)
|
||||
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
nt := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
nt.Handle(ntSvc)
|
||||
go nt.Run()
|
||||
|
||||
// The onboarder agent, registered so the flow can reach it over RPC.
|
||||
onboarder := agent.New(
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
|
||||
agent.Provider(*provider),
|
||||
|
||||
@@ -36,14 +36,14 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
ws := service.New(service.Name("workspace"), service.Registry(reg), service.Client(cl))
|
||||
if err := ws.Handle(wsSvc); err != nil {
|
||||
t.Fatalf("handle workspace: %v", err)
|
||||
}
|
||||
go ws.Run()
|
||||
|
||||
ntSvc := new(NotifyService)
|
||||
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
nt := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
if err := nt.Handle(ntSvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -51,7 +51,6 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
|
||||
|
||||
onboarder := agent.New(
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
|
||||
agent.Provider("mock"),
|
||||
|
||||
@@ -23,15 +23,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,95 +236,52 @@ func main() {
|
||||
fmt.Printf("\n\033[1mPlan & Delegate — live integration harness (provider: %s)\033[0m\n", *provider)
|
||||
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
// Real services.
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
fmt.Println("task handle:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
task := micro.NewService("task")
|
||||
task.Handle(new(TaskService))
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
fmt.Println("notify handle:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
notify := micro.NewService("notify")
|
||||
notify.Handle(new(NotifyService))
|
||||
go notify.Run()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
comms := micro.NewAgent("comms",
|
||||
micro.AgentServices("notify"),
|
||||
micro.AgentPrompt("You handle outbound notifications. Use the notify service."),
|
||||
micro.AgentProvider(*provider),
|
||||
micro.AgentAPIKey(apiKey),
|
||||
)
|
||||
go comms.Run()
|
||||
defer comms.Stop()
|
||||
|
||||
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
agent.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
// Real conductor agent (owns task).
|
||||
conductor := micro.NewAgent("conductor",
|
||||
micro.AgentServices("task"),
|
||||
micro.AgentPrompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
micro.AgentProvider(*provider),
|
||||
micro.AgentAPIKey(apiKey),
|
||||
)
|
||||
go conductor.Run()
|
||||
defer conductor.Stop()
|
||||
|
||||
fmt.Println("waiting for services + agents to register...")
|
||||
waitForService := func(name string) {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"task", "notify", "comms", "conductor"} {
|
||||
waitForService(name)
|
||||
}
|
||||
fmt.Println("waiting for services + comms agent to register...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Print("\n\033[1m> prompt:\033[0m Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified.\n\n")
|
||||
|
||||
fmt.Print("\n\033[1m> flow:\033[0m services + agents + workflow + plan/delegate, no API key.\n\n")
|
||||
if err := f.Execute(context.Background(), "launch readiness"); err != nil {
|
||||
resp, err := conductor.Ask(context.Background(),
|
||||
"Create three launch tasks: Design, Build, and Ship. Then make sure owner@acme.com is notified that the launch plan is ready.")
|
||||
if err != nil {
|
||||
fmt.Println("\033[31merror:\033[0m", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rs[len(rs)-1].Reply)
|
||||
}
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", resp.Reply)
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
if recs, _ := store.Scope(mem, "agent", "conductor").Read("plan"); len(recs) > 0 {
|
||||
if recs, _ := conductor.Options().Store.Read("agent/conductor/plan"); len(recs) > 0 {
|
||||
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
|
||||
} else {
|
||||
fmt.Println("\n\033[31m! plan was not persisted\033[0m")
|
||||
os.Exit(1)
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
fmt.Printf("\n\033[31m! unexpected side effects: tasks=%d notify=%d\033[0m\n", taskSvc.count(), notifySvc.count())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\n\033[32m✓ 0→hero flow complete (services → agents → workflow)\033[0m")
|
||||
fmt.Println("\n\033[32m✓ end-to-end flow complete\033[0m")
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
|
||||
// Real services on the shared registry/client.
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
task := service.New(service.Name("task"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
notify := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -63,7 +63,6 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Provider("mock"),
|
||||
@@ -81,7 +80,6 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
// Real conductor agent (owns task), driven programmatically.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
|
||||
agent.Provider("mock"),
|
||||
@@ -130,14 +128,14 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
task := service.New(service.Name("task"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
t.Fatalf("handle task: %v", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
notify := service.New(service.Name("notify"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
t.Fatalf("handle notify: %v", err)
|
||||
}
|
||||
@@ -145,7 +143,6 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Provider("mock"),
|
||||
@@ -160,7 +157,6 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
// so the flow can reach it over RPC.
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
|
||||
agent.Provider("mock"),
|
||||
|
||||
@@ -14,7 +14,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -23,15 +22,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
var providerEnv = map[string]string{
|
||||
@@ -48,11 +38,6 @@ func main() {
|
||||
providersFlag := flag.String("providers", "anthropic,openai,gemini,groq,mistral,together,atlascloud", "comma-separated providers to check; use mock for deterministic local checks")
|
||||
harnessesFlag := flag.String("harnesses", "universe,agent-flow,plan-delegate", "comma-separated harness names under internal/harness")
|
||||
timeoutFlag := flag.Duration("timeout", 10*time.Minute, "timeout per provider/harness run")
|
||||
requireConfiguredFlag := flag.Bool("require-configured", false, "fail when a selected live provider is missing an API key")
|
||||
capabilitiesFlag := flag.Bool("capabilities", true, "print the registered provider capability matrix before running conformance")
|
||||
summaryJSONFlag := flag.String("summary-json", "", "write a machine-readable conformance summary to this path")
|
||||
summaryMarkdownFlag := flag.String("summary-markdown", "", "write a human-readable conformance summary to this path")
|
||||
capabilityMarkdownFlag := flag.String("capabilities-markdown", "", "write the registered provider capability matrix as a Markdown table")
|
||||
flag.Parse()
|
||||
|
||||
providers := splitCSV(*providersFlag)
|
||||
@@ -62,30 +47,11 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *capabilitiesFlag {
|
||||
printCapabilityMatrix()
|
||||
}
|
||||
if *capabilityMarkdownFlag != "" {
|
||||
if err := writeCapabilityMarkdown(*capabilityMarkdownFlag, ai.CapabilityRows()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write capabilities markdown: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var ran, skipped, failed int
|
||||
var results []conformanceResult
|
||||
for _, provider := range providers {
|
||||
if provider != "mock" && providerKey(provider) == "" {
|
||||
msg := fmt.Sprintf("set MICRO_AI_API_KEY or %s", providerEnv[provider])
|
||||
if *requireConfiguredFlag {
|
||||
fmt.Printf("FAIL %s: missing API key (%s)\n", provider, msg)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Status: statusFailed, Error: "missing API key: " + msg})
|
||||
} else {
|
||||
fmt.Printf("- %s: skipped (%s)\n", provider, msg)
|
||||
skipped++
|
||||
results = append(results, conformanceResult{Provider: provider, Status: statusSkipped, Error: msg})
|
||||
}
|
||||
fmt.Printf("- %s: skipped (set MICRO_AI_API_KEY or %s)\n", provider, providerEnv[provider])
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -94,138 +60,18 @@ func main() {
|
||||
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
|
||||
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nprovider conformance: %d passed, %d skipped providers, %d failed\n", ran, skipped, failed)
|
||||
summary := conformanceSummary{
|
||||
Providers: providers,
|
||||
Harnesses: harnesses,
|
||||
Capabilities: ai.CapabilityRows(),
|
||||
Results: results,
|
||||
Passed: ran,
|
||||
Skipped: skipped,
|
||||
Failed: failed,
|
||||
}
|
||||
if *summaryJSONFlag != "" {
|
||||
if err := writeSummaryJSON(*summaryJSONFlag, summary); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write summary: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if *summaryMarkdownFlag != "" {
|
||||
if err := writeSummaryMarkdown(*summaryMarkdownFlag, summary); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write summary markdown: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
statusPassed = "passed"
|
||||
statusSkipped = "skipped"
|
||||
statusFailed = "failed"
|
||||
)
|
||||
|
||||
type conformanceResult struct {
|
||||
Provider string `json:"provider"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type conformanceSummary struct {
|
||||
Providers []string `json:"providers"`
|
||||
Harnesses []string `json:"harnesses"`
|
||||
Capabilities []ai.CapabilityRow `json:"capabilities"`
|
||||
Results []conformanceResult `json:"results"`
|
||||
Passed int `json:"passed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
func writeSummaryJSON(path string, summary conformanceSummary) error {
|
||||
b, err := json.MarshalIndent(summary, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func writeCapabilityMarkdown(path string, rows []ai.CapabilityRow) error {
|
||||
return os.WriteFile(path, []byte(capabilityMarkdown(rows)), 0o644)
|
||||
}
|
||||
|
||||
func writeSummaryMarkdown(path string, summary conformanceSummary) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Provider conformance summary\n\n")
|
||||
fmt.Fprintf(&b, "Passed: %d. Skipped providers: %d. Failed: %d.\n\n", summary.Passed, summary.Skipped, summary.Failed)
|
||||
b.WriteString("## Capability matrix\n\n")
|
||||
b.WriteString(capabilityMarkdown(summary.Capabilities))
|
||||
b.WriteString("\n## Harness results\n\n")
|
||||
b.WriteString("| Provider | Harness | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- |\n")
|
||||
for _, result := range summary.Results {
|
||||
harness := result.Harness
|
||||
if harness == "" {
|
||||
harness = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func capabilityMarkdown(rows []ai.CapabilityRow) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("| Provider | Model | Image | Video | Streaming |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func markdownCell(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||
return s
|
||||
}
|
||||
|
||||
func mark(ok bool) string {
|
||||
if ok {
|
||||
return "✅"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func printCapabilityMatrix() {
|
||||
fmt.Println("Provider capability matrix:")
|
||||
fmt.Println("provider model image video stream")
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
fmt.Printf("%-12s %-5s %-5s %-5s %-6s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func yesNo(ok bool) string {
|
||||
if ok {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func validateSelection(providers, harnesses []string) error {
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("no providers selected")
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestValidateSelectionAcceptsKnownProviderAndHarness(t *testing.T) {
|
||||
@@ -35,115 +30,3 @@ func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
|
||||
t.Fatalf("validateSelection error = %q, want invalid harness message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
rows := ai.CapabilityRows()
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("CapabilityRows returned no providers")
|
||||
}
|
||||
|
||||
var foundOpenAI bool
|
||||
for _, row := range rows {
|
||||
if row.Provider == "openai" {
|
||||
foundOpenAI = true
|
||||
if !row.Model || !row.Image || row.Video {
|
||||
t.Fatalf("openai capabilities = %#v, want model+image only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOpenAI {
|
||||
t.Fatalf("CapabilityRows = %#v, want openai row", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCapabilityMarkdown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "capabilities.md")
|
||||
rows := []ai.CapabilityRow{
|
||||
{Provider: "mock", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "vision", Capabilities: ai.Capabilities{Image: true, Video: true}},
|
||||
}
|
||||
if err := writeCapabilityMarkdown(path, rows); err != nil {
|
||||
t.Fatalf("writeCapabilityMarkdown returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read capabilities markdown: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
"| Provider | Model | Image | Video | Streaming |",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| vision | — | ✅ | ✅ | — |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("capabilities markdown = %q, want row %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSummaryMarkdown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.md")
|
||||
summary := conformanceSummary{
|
||||
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
|
||||
Results: []conformanceResult{
|
||||
{Provider: "mock", Harness: "agent-flow", Status: statusPassed},
|
||||
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
|
||||
},
|
||||
Passed: 1,
|
||||
Skipped: 1,
|
||||
}
|
||||
if err := writeSummaryMarkdown(path, summary); err != nil {
|
||||
t.Fatalf("writeSummaryMarkdown returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read summary markdown: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
"# Provider conformance summary",
|
||||
"Passed: 1. Skipped providers: 1. Failed: 0.",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| mock | agent-flow | passed | — |",
|
||||
"| live | — | skipped | missing \\| key |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("summary markdown = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSummaryJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.json")
|
||||
summary := conformanceSummary{
|
||||
Providers: []string{"mock"},
|
||||
Harnesses: []string{"provider-conformance"},
|
||||
Results: []conformanceResult{{
|
||||
Provider: "mock",
|
||||
Harness: "provider-conformance",
|
||||
Status: statusPassed,
|
||||
}},
|
||||
Passed: 1,
|
||||
}
|
||||
if err := writeSummaryJSON(path, summary); err != nil {
|
||||
t.Fatalf("writeSummaryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read summary: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(string(b), "\n") {
|
||||
t.Fatalf("summary JSON should end with newline: %q", b)
|
||||
}
|
||||
|
||||
var got conformanceSummary
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("summary JSON did not decode: %v", err)
|
||||
}
|
||||
if got.Passed != 1 || len(got.Results) != 1 || got.Results[0].Status != statusPassed {
|
||||
t.Fatalf("summary JSON decoded as %#v, want one passed result", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,27 +207,23 @@ func waitFor(reg registry.Registry, names ...string) {
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
|
||||
flag.Parse()
|
||||
os.Exit(runUniverse(*provider))
|
||||
}
|
||||
|
||||
func runUniverse(provider string) int {
|
||||
failures = 0
|
||||
apiKey := ""
|
||||
if provider == "mock" {
|
||||
if *provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else if apiKey = providerKey(provider); apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
|
||||
return 2
|
||||
} else if apiKey = providerKey(*provider); apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mUNIVERSE — booting a mini go-micro world (provider: %s)\033[0m\n\n", provider)
|
||||
fmt.Printf("\n\033[1mUNIVERSE — booting a mini go-micro world (provider: %s)\033[0m\n\n", *provider)
|
||||
|
||||
// Infrastructure — all in-memory, all real.
|
||||
reg := registry.NewMemoryRegistry()
|
||||
br := broker.NewMemoryBroker()
|
||||
if err := br.Connect(); err != nil {
|
||||
fmt.Println("broker connect:", err)
|
||||
return 2
|
||||
os.Exit(2)
|
||||
}
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
st := store.NewMemoryStore()
|
||||
@@ -235,7 +231,7 @@ func runUniverse(provider string) int {
|
||||
// Services.
|
||||
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
|
||||
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Registry(reg), service.Client(cl))
|
||||
svc.Handle(h)
|
||||
go svc.Run()
|
||||
}
|
||||
@@ -247,8 +243,7 @@ func runUniverse(provider string) int {
|
||||
agent.Name("concierge"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You notify buyers when their order is confirmed."),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.MaxSteps(5),
|
||||
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
@@ -277,7 +272,7 @@ func runUniverse(provider string) int {
|
||||
)
|
||||
if err := checkout.Register(reg, br, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
return 2
|
||||
os.Exit(2)
|
||||
}
|
||||
defer checkout.Stop()
|
||||
|
||||
@@ -287,7 +282,7 @@ func runUniverse(provider string) int {
|
||||
fmt.Println("\033[1m> event:\033[0m events.order.placed {\"order\":\"order-1\"}")
|
||||
if err := br.Publish("events.order.placed", &broker.Message{Body: []byte(`{"order":"order-1"}`)}); err != nil {
|
||||
fmt.Println("publish:", err)
|
||||
return 2
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Wait for the run to be checkpointed as failed at "charge".
|
||||
@@ -357,8 +352,7 @@ func runUniverse(provider string) int {
|
||||
|
||||
if failures > 0 {
|
||||
fmt.Printf("\n\033[31m✗ universe failed: %d assertion(s)\033[0m\n", failures)
|
||||
return 1
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ universe: booted, survived a crash, resumed, and shut down cleanly\033[0m")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
|
||||
// Go test contract. The harness boots real services, a durable workflow, an
|
||||
// agent, scoped state, and the A2A gateway with only the LLM mocked; running it
|
||||
// here prevents the full services → agents → workflows lifecycle from silently
|
||||
// drifting while developers rely on `go test ./...`.
|
||||
func TestUniverseHarnessContract(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("universe harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
|
||||
if code := runUniverse("mock"); code != 0 {
|
||||
t.Fatalf("universe harness exited with code %d", code)
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,6 @@ guides:
|
||||
url: /docs/guides/atlascloud-integration.html
|
||||
- title: AI Provider Guide
|
||||
url: /docs/guides/ai-provider-guide.html
|
||||
- title: Provider Conformance
|
||||
url: /docs/guides/provider-conformance.html
|
||||
- title: Comparison
|
||||
url: /docs/guides/comparison.html
|
||||
- title: Migration Guides
|
||||
@@ -82,7 +80,6 @@ search_order:
|
||||
- /docs/plugins.html
|
||||
- /docs/examples/
|
||||
- /docs/examples/realworld/
|
||||
- /docs/guides/provider-conformance.html
|
||||
- /docs/guides/comparison.html
|
||||
- /docs/guides/migration/
|
||||
- /docs/architecture/
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
layout: blog
|
||||
title: "How Go Micro Builds Itself"
|
||||
permalink: /blog/31
|
||||
description: "Go Micro is increasingly built by an autonomous loop of two AI agents — Codex implementing scoped increments, Claude Code orchestrating, the human setting direction. Here's how the loop actually works, including the parts that broke."
|
||||
---
|
||||
|
||||
# How Go Micro Builds Itself
|
||||
|
||||
*June 25, 2026 • By the Go Micro Team*
|
||||
|
||||
Go Micro is an agent harness. The most honest test of that claim is to use agents to build it — so increasingly, we do. A scheduled loop of two AI agents now opens issues, writes increments, and merges its own pull requests against this repo, on a cadence, with a human setting direction rather than typing the code.
|
||||
|
||||
This isn't a stunt. If a harness is good enough to operate a loop that builds itself, that's evidence it's good enough to operate the loop that builds *your* software. So we pointed the thesis at itself and wired up the loop. This post is how it actually works — including the parts that didn't, because the failure modes are the interesting part.
|
||||
|
||||
## Two agents and a human
|
||||
|
||||
The work splits across three roles:
|
||||
|
||||
- **Codex** is the serial builder. It takes one scoped task at a time, implements it, runs the build, tests, and linter, and opens a pull request.
|
||||
- **Claude Code** is the orchestrator: it sets up the machinery, reviews, integrates, and handles the judgment calls Codex shouldn't make alone.
|
||||
- **The human** sets direction and owns taste — brand and positioning copy, breaking public API changes, architectural decisions. Those never merge autonomously.
|
||||
|
||||
Everything else is automated.
|
||||
|
||||
## The mechanism
|
||||
|
||||
Each cycle is deliberately boring, which is the point:
|
||||
|
||||
1. A scheduled workflow opens a **fresh tracking issue** and dispatches Codex on it with a single instruction: pick the highest-value improvement that advances the [North Star](/docs/guides/agent-harness.html), implement it, verify it builds and passes tests and lint, and open a PR.
|
||||
2. Codex does the work on its own branch and opens the PR.
|
||||
3. GitHub **native auto-merge** lands it the moment the required CI checks go green — build, tests, golangci-lint. There is no human approval step. **CI is the only gate**, and that's not an approval, it's just a refusal to ship broken code.
|
||||
|
||||
Every increment is small, single-concern, and reversible. Nothing clever survives that can't pass the same checks a human contributor's PR would.
|
||||
|
||||
## Three altitudes
|
||||
|
||||
One loop only produces increments; it doesn't know whether they're adding up to anything. So there are three passes at different altitudes:
|
||||
|
||||
- An **architect** pass reviews the whole framework against the thesis every few days — API coherence, gaps in the services → agents → workflows lifecycle, drift — and files scoped issues. It decides *what* to build.
|
||||
- The **hourly increment loop** builds those issues.
|
||||
- A **DevRel** pass audits the README, website, docs, and blog each day for coherence, and surfaces things worth writing about. (This post is the kind of thing it's meant to catch.)
|
||||
|
||||
The architect points, the loop builds, DevRel keeps the story honest. Direction flows down; code flows up.
|
||||
|
||||
## The parts that broke
|
||||
|
||||
Wiring an autonomous loop is mostly plumbing and failure modes, which is exactly why it's a good test of a harness. A few we hit:
|
||||
|
||||
- The agent's "open a pull request" tool turned out to be a **stub** — it recorded the PR's title and body and returned them for a downstream step, but never pushed a branch or called the API. The agent cheerfully reported "opened a PR" every time, and no PR ever appeared. The fix was to stop trusting the tool and have the agent push and open the PR itself.
|
||||
- Dispatching every run from a single tracking issue made the agent derive the **same branch name** each time, so the first increment opened a PR and the rest silently collided. One fresh issue per run fixed it.
|
||||
- At one point an increment helpfully rewrote the repo's own agent instructions to point *back* at the broken tool. An autonomous loop will faithfully encode its own mistakes, so the guardrails have to be explicit.
|
||||
|
||||
None of these are exotic. They're the ordinary reality of operating an agent loop: tools that lie, state that collides, instructions that drift. The things the harness ships — observability, durable runs, resilience, guardrails — are the things you reach for the moment you try to run a loop like this in earnest.
|
||||
|
||||
## What it produces
|
||||
|
||||
The increments are unglamorous and real. Recent ones hardened the agent run loop with **OpenTelemetry run timelines** and a `micro runs` command to inspect them, correlated those timelines with trace spans, added **retry backoff** to durable flows, and made flow steps **cancellation-safe** so a canceled run stops retrying instead of burning its budget. Each one landed as a small PR that passed CI on its own.
|
||||
|
||||
That's the texture of the work: not a model writing a framework in one shot, but a loop making it a little better, continuously, under a gate that keeps it honest.
|
||||
|
||||
## The loop is the proof
|
||||
|
||||
We think the future of agentic software is scheduled, looping, work-performing agents — not chat. Go Micro is built by exactly that, against its own repo. The human still sets direction and owns the calls that need taste; CI is the gate; everything is reversible. Within those bounds, the harness builds itself.
|
||||
|
||||
If it can do that, it can build yours.
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is an open source agent harness and service framework for Go. [Star us on GitHub](https://github.com/micro/go-micro).*
|
||||
|
||||
<div class="post-nav">
|
||||
<div><a href="/blog/30">← Go Micro is an Agent Harness</a></div>
|
||||
<div><a href="/blog/">All Posts</a></div>
|
||||
</div>
|
||||
@@ -11,13 +11,6 @@ permalink: /blog/
|
||||
|
||||
<div class="posts">
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/31">How Go Micro Builds Itself</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">June 25, 2026</p>
|
||||
<p>Go Micro is increasingly built by an autonomous loop of two AI agents — Codex writing scoped increments, Claude Code orchestrating, the human setting direction. Here's how the loop actually works, including the parts that broke.</p>
|
||||
<a href="/blog/31">Read more →</a>
|
||||
</article>
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/30">Go Micro is an Agent Harness</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">June 24, 2026</p>
|
||||
|
||||
@@ -66,7 +66,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("example",
|
||||
service := micro.NewService(
|
||||
micro.Name("example"),
|
||||
)
|
||||
service.Init()
|
||||
if err := service.Run(); err != nil {
|
||||
|
||||
@@ -31,11 +31,12 @@ Use **mDNS as the default registry** for service discovery.
|
||||
|
||||
```go
|
||||
// Default - uses mDNS automatically
|
||||
svc := micro.NewService("myservice")
|
||||
svc := micro.NewService(micro.Name("myservice"))
|
||||
|
||||
// Production - swap to Consul
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService("myservice",
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
micro.Registry(reg),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ Implement **progressive configuration** where:
|
||||
|
||||
### Level 1: Zero Config (Development)
|
||||
```go
|
||||
svc := micro.NewService("hello")
|
||||
svc := micro.NewService(micro.Name("hello"))
|
||||
svc.Run()
|
||||
```
|
||||
|
||||
@@ -62,7 +62,8 @@ b := nats.NewNatsBroker(
|
||||
nats.DrainConnection(),
|
||||
)
|
||||
|
||||
svc := micro.NewService("myservice",
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
micro.Version("1.2.3"),
|
||||
micro.Registry(reg),
|
||||
micro.Broker(b),
|
||||
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("publisher")
|
||||
service := micro.NewService()
|
||||
service.Init()
|
||||
|
||||
// Publish a message
|
||||
@@ -73,7 +73,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := bnats.NewNatsBroker()
|
||||
svc := micro.NewService("publisher", micro.Broker(b))
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -88,7 +88,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := rabbitmq.NewBroker()
|
||||
svc := micro.NewService("publisher", micro.Broker(b))
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ func (g *Greeter) Hello(ctx context.Context, req *struct{}, rsp *struct{Msg stri
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("greeter",
|
||||
service := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
)
|
||||
service.Init()
|
||||
micro.RegisterHandler(service.Server(), new(Greeter))
|
||||
|
||||
@@ -53,7 +53,8 @@ No code changes required. The framework internally wires the selected implementa
|
||||
## Equivalent Code Configuration
|
||||
|
||||
```go
|
||||
service := micro.NewService("helloworld",
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
micro.Broker(nats.NewBroker()),
|
||||
micro.Transport(natstransport.NewTransport()),
|
||||
micro.Registry(consul.NewRegistry(registry.Addrs("127.0.0.1:8500"))),
|
||||
|
||||
@@ -54,7 +54,8 @@ curl -XPOST \
|
||||
Set a fixed address:
|
||||
|
||||
```go
|
||||
svc := micro.NewService("helloworld",
|
||||
svc := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := bnats.NewNatsBroker()
|
||||
svc := micro.NewService("nats-pubsub", micro.Broker(b))
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc.Init()
|
||||
|
||||
// subscribe
|
||||
|
||||
@@ -79,7 +79,8 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
svc := micro.NewService("users",
|
||||
svc := micro.NewService(
|
||||
micro.Name("users"),
|
||||
micro.Version("1.0.0"),
|
||||
)
|
||||
|
||||
@@ -171,7 +172,8 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
svc := micro.NewService("orders",
|
||||
svc := micro.NewService(
|
||||
micro.Name("orders"),
|
||||
micro.Version("1.0.0"),
|
||||
)
|
||||
|
||||
@@ -252,7 +254,8 @@ func (g *Gateway) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService("api.gateway",
|
||||
svc := micro.NewService(
|
||||
micro.Name("api.gateway"),
|
||||
)
|
||||
svc.Init()
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService("myservice",
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
micro.BeforeStop(func() error {
|
||||
logger.Info("Service stopping, running cleanup...")
|
||||
return cleanup()
|
||||
@@ -232,7 +233,8 @@ func main() {
|
||||
app.AddWorker(&Worker{name: "cleanup"})
|
||||
app.AddWorker(&Worker{name: "metrics"})
|
||||
|
||||
svc := micro.NewService("myservice",
|
||||
svc := micro.NewService(
|
||||
micro.Name("myservice"),
|
||||
micro.BeforeStop(func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func main() {
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService("consul-registry", micro.Registry(reg))
|
||||
svc := micro.NewService(micro.Registry(reg))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := postgres.NewStore()
|
||||
svc := micro.NewService("postgres-store", micro.Store(st))
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc.Init()
|
||||
|
||||
_ = store.Write(&store.Record{Key: "foo", Value: []byte("bar")})
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func main() {
|
||||
t := tnats.NewTransport()
|
||||
svc := micro.NewService("nats-transport", micro.Transport(t))
|
||||
svc := micro.NewService(micro.Transport(t))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -132,10 +132,9 @@ yet terminal, it polls `tasks/get` until it completes.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the JSON-RPC binding for completed-task execution:
|
||||
This is the synchronous JSON-RPC binding:
|
||||
|
||||
- **`message/send`** runs the agent and returns a completed `Task`.
|
||||
- **`message/stream`** streams the completed `Task` as an SSE `data:` event, giving A2A clients a streaming-compatible path while the underlying agent call remains synchronous.
|
||||
- **`tasks/get`** returns a recent task by id.
|
||||
- **Agent Card** discovery, generated from the registry.
|
||||
|
||||
@@ -143,11 +142,11 @@ Both directions work: the gateway exposes your agents, and `a2a.Client` (via `fl
|
||||
|
||||
Not yet supported (advertised as such on the card, so clients negotiate correctly):
|
||||
|
||||
- **`tasks/resubscribe`** for reconnecting to a live stream.
|
||||
- **`message/stream`** (SSE streaming) and `tasks/resubscribe`.
|
||||
- Multi-turn `input-required` tasks.
|
||||
- Push notifications.
|
||||
|
||||
These are the natural follow-ups; the completed-task binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
|
||||
These are the natural follow-ups; the synchronous binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -71,5 +71,4 @@ harness. When that loop has to touch production, you do.
|
||||
- [Agent Loops](agent-loops.html) — run-until-done, with a ceiling
|
||||
- [Plan & Delegate](plan-delegate.html)
|
||||
- [Agent Guardrails](agent-guardrails.html)
|
||||
- [Provider Conformance](provider-conformance.html) — verified provider behavior
|
||||
- [Roadmap](/docs/roadmap.html)
|
||||
|
||||
@@ -21,31 +21,6 @@ ai/
|
||||
└── yourprovider_test.go # Unit tests
|
||||
```
|
||||
|
||||
|
||||
## Discover registered provider capabilities
|
||||
|
||||
Go Micro exposes the provider interfaces registered in the current build, so
|
||||
runtime tooling and docs can report what is actually available after blank
|
||||
imports are linked in:
|
||||
|
||||
```go
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
fmt.Printf("%s: chat=%t image=%t video=%t stream=%t\n", row.Provider, row.Model, row.Image, row.Video, row.Stream)
|
||||
}
|
||||
```
|
||||
|
||||
The built-in providers currently register these capability interfaces:
|
||||
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | No |
|
||||
| `atlascloud` | Yes | Yes | Yes | No |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | No |
|
||||
| `mistral` | Yes | No | No | No |
|
||||
| `openai` | Yes | Yes | No | No |
|
||||
| `together` | Yes | No | No | No |
|
||||
|
||||
## Step 1: Implement the `ai.Model` Interface
|
||||
|
||||
Every provider must satisfy `ai.Model`:
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *MyService) DoThing(ctx context.Context, req *Request, rsp *Response) er
|
||||
}
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService("myservice")
|
||||
svc := micro.NewService(micro.Name("myservice"))
|
||||
svc.Init()
|
||||
svc.Handle(new(MyService))
|
||||
svc.Run()
|
||||
@@ -140,7 +140,7 @@ import (
|
||||
grpcClient "go-micro.dev/v6/client/grpc"
|
||||
)
|
||||
|
||||
svc := micro.NewService("myservice",
|
||||
svc := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
)
|
||||
@@ -234,11 +234,11 @@ mesh / runtime and let ADK (or any A2A agent) plug into it.
|
||||
**Go Micro**: Built-in with plugins
|
||||
```go
|
||||
// Zero-config for dev
|
||||
svc := micro.NewService("myservice")
|
||||
svc := micro.NewService(micro.Name("myservice"))
|
||||
|
||||
// Consul for production
|
||||
reg := consul.NewRegistry()
|
||||
svc := micro.NewService("myservice", micro.Registry(reg))
|
||||
svc := micro.NewService(micro.Registry(reg))
|
||||
```
|
||||
|
||||
**go-kit**: Bring your own
|
||||
|
||||
@@ -19,7 +19,8 @@ The gRPC **transport** uses the gRPC protocol as a communication layer, similar
|
||||
import "go-micro.dev/v6/transport/grpc"
|
||||
|
||||
t := grpc.NewTransport()
|
||||
service := micro.NewService("helloworld",
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
micro.Transport(t),
|
||||
)
|
||||
```
|
||||
@@ -41,12 +42,15 @@ import (
|
||||
grpcClient "go-micro.dev/v6/client/grpc"
|
||||
)
|
||||
|
||||
service := micro.NewService("helloworld",
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()), // Server must come before Name
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
micro.Name("helloworld"),
|
||||
)
|
||||
```
|
||||
|
||||
> **Important**: The `micro.Server()` option must be specified **before** `micro.Name()`. This is because `micro.Name()` sets the name on the current server, and if `micro.Server()` comes after, it replaces the server with a new one that has no name set.
|
||||
|
||||
## When to Use Which
|
||||
|
||||
| Use Case | Solution |
|
||||
@@ -119,8 +123,9 @@ func (s *Say) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) erro
|
||||
func main() {
|
||||
// Create service with gRPC server for native gRPC compatibility
|
||||
// Note: Server must be set before Name to ensure the name is applied to the gRPC server
|
||||
service := micro.NewService("helloworld",
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
micro.Name("helloworld"),
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
|
||||
@@ -153,8 +158,9 @@ import (
|
||||
|
||||
func main() {
|
||||
// Create service with gRPC client
|
||||
service := micro.NewService("helloworld.client",
|
||||
service := micro.NewService(
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
micro.Name("helloworld.client"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
@@ -201,9 +207,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("helloworld",
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()), // Server first
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
micro.Name("helloworld"), // Name after Server
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
|
||||
@@ -232,7 +239,7 @@ ERROR:
|
||||
```go
|
||||
// Wrong - uses transport
|
||||
t := grpc.NewTransport()
|
||||
service := micro.NewService("helloworld",
|
||||
service := micro.NewService(
|
||||
micro.Transport(t),
|
||||
)
|
||||
```
|
||||
@@ -243,7 +250,7 @@ To:
|
||||
// Correct - uses server
|
||||
import grpcServer "go-micro.dev/v6/server/grpc"
|
||||
|
||||
service := micro.NewService("helloworld",
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
)
|
||||
```
|
||||
@@ -263,12 +270,36 @@ import "go-micro.dev/v6/server/grpc"
|
||||
import "go-micro.dev/v6/client/grpc"
|
||||
```
|
||||
|
||||
### Service Name vs Package Name
|
||||
### Option Ordering Issue
|
||||
|
||||
When creating a client to call another service, use the **service name** passed to `micro.NewService`, not the proto package name:
|
||||
If the gRPC server is working but your service has no name or is not being found in the registry:
|
||||
|
||||
**Cause**: The `micro.Server()` option is specified **after** `micro.Name()`.
|
||||
|
||||
When options are processed, `micro.Name()` sets the name on the current server. If `micro.Server()` comes later, it replaces the server with a new one that doesn't have the name set.
|
||||
|
||||
**Solution**: Always specify `micro.Server()` **before** `micro.Name()`:
|
||||
|
||||
```go
|
||||
// If the server was started with micro.NewService("helloworld", ...)
|
||||
// Wrong - server replaces the one with the name set
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"), // Sets name on default server
|
||||
micro.Server(grpcServer.NewServer()), // Replaces server, name is lost!
|
||||
)
|
||||
|
||||
// Correct - name is set on the gRPC server
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()), // Set server first
|
||||
micro.Name("helloworld"), // Name is now applied to gRPC server
|
||||
)
|
||||
```
|
||||
|
||||
### Service Name vs Package Name
|
||||
|
||||
When creating a client to call another service, use the **service name** (set via `micro.Name()`), not the proto package name:
|
||||
|
||||
```go
|
||||
// If the server was started with micro.Name("helloworld")
|
||||
sayService := pb.NewSayService("helloworld", service.Client()) // Use service name
|
||||
|
||||
// NOT the package name from the proto file
|
||||
|
||||
@@ -319,7 +319,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("tasks",
|
||||
service := micro.NewService(
|
||||
micro.Name("tasks"),
|
||||
micro.Address(":8081"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
@@ -128,7 +128,8 @@ func (s *Greeter) SayHello(ctx context.Context, req *pb.HelloRequest, rsp *pb.He
|
||||
}
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
)
|
||||
svc.Init()
|
||||
|
||||
@@ -158,7 +159,7 @@ rsp, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "John"}
|
||||
|
||||
**Go Micro client:**
|
||||
```go
|
||||
svc := micro.NewService("client")
|
||||
svc := micro.NewService(micro.Name("client"))
|
||||
svc.Init()
|
||||
|
||||
client := pb.NewGreeterService("greeter", svc.Client())
|
||||
@@ -184,7 +185,8 @@ import (
|
||||
grpcserver "go-micro.dev/v6/server/grpc"
|
||||
)
|
||||
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
micro.Client(grpcclient.NewClient()),
|
||||
micro.Server(grpcserver.NewServer()),
|
||||
)
|
||||
@@ -266,7 +268,8 @@ defer client.Agent().ServiceDeregister("greeter-1")
|
||||
import "go-micro.dev/v6/registry/consul"
|
||||
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
micro.Registry(reg),
|
||||
)
|
||||
|
||||
@@ -290,7 +293,7 @@ svc.Run()
|
||||
import "go-micro.dev/v6/selector"
|
||||
|
||||
// Client-side load balancing built-in
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Selector(selector.NewSelector(
|
||||
selector.SetStrategy(selector.RoundRobin),
|
||||
)),
|
||||
@@ -359,10 +362,11 @@ lis, _ := net.Listen("tcp", ":50051")
|
||||
**Go Micro**: Automatic or explicit
|
||||
```go
|
||||
// Let Go Micro choose
|
||||
svc := micro.NewService("greeter")
|
||||
svc := micro.NewService(micro.Name("greeter"))
|
||||
|
||||
// Or specify
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
micro.Address(":50051"),
|
||||
)
|
||||
```
|
||||
@@ -386,7 +390,7 @@ Ensure both use protobuf:
|
||||
```go
|
||||
import "go-micro.dev/v6/codec/proto"
|
||||
|
||||
svc := micro.NewService("greeter",
|
||||
svc := micro.NewService(
|
||||
micro.Codec("application/protobuf", proto.Marshaler{}),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Provider Conformance Matrix
|
||||
|
||||
Go Micro treats model providers as interchangeable pieces of the same agent
|
||||
harness: services expose tools, agents reason over them, and workflows stitch the
|
||||
work together. The conformance harness keeps that promise honest by running the
|
||||
same deterministic services → agents → workflows scenarios against every
|
||||
configured provider.
|
||||
|
||||
The live harness is in `internal/harness/provider-conformance`. It skips
|
||||
providers without API keys by default, so it is safe to run locally, and it fails
|
||||
when any configured provider breaks the shared contract.
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance
|
||||
```
|
||||
|
||||
For a no-key smoke test of the same harness wiring, run the mock provider:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance -providers mock
|
||||
```
|
||||
|
||||
## Status legend
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| ✅ Verified | Covered by the provider-conformance harness for configured live providers. |
|
||||
| ⚠️ Unverified | Implemented in the public API, but not yet exercised by provider conformance. |
|
||||
| — Unsupported | Not exposed by that provider integration today. |
|
||||
|
||||
## Harness coverage by capability
|
||||
|
||||
These rows describe what the conformance harness verifies today. A provider is
|
||||
considered conformant when the configured-key run passes all selected harnesses.
|
||||
|
||||
| Capability | Harness coverage | Notes |
|
||||
| --- | --- | --- |
|
||||
| Simple generation | ✅ Verified | Each harness asks the provider to produce an agent response through `ai.Model`. |
|
||||
| Service tool calls | ✅ Verified | Harness services are discovered and invoked as model-selected tools. |
|
||||
| Multi-step tool use | ✅ Verified | The `universe` and `plan-delegate` harnesses require more than one service/tool action. |
|
||||
| `plan` | ✅ Verified | `plan-delegate` verifies that the conductor agent stores a plan in scoped state. |
|
||||
| `delegate` | ✅ Verified | `plan-delegate` verifies agent-to-agent delegation over real RPC. |
|
||||
| Guardrail/stop behavior | ✅ Verified | `universe` runs with guardrails enabled and asserts the guarded path completes. |
|
||||
| Streaming | ⚠️ Unverified | `ai.Model.Stream` exists on the interface, but end-to-end streaming conformance is a roadmap item. |
|
||||
| Structured errors | ⚠️ Unverified | Error handling is covered by normal test suites, but provider conformance does not yet compare structured provider errors. |
|
||||
|
||||
## Provider capability matrix
|
||||
|
||||
This matrix combines the registered provider interfaces with the conformance
|
||||
coverage above. The chat/text column is the harness path: when the provider has a
|
||||
configured key, the conformance command exercises the verified rows in the
|
||||
previous section.
|
||||
|
||||
| Provider | Chat/text agent harness | Image | Video | Streaming | Structured errors |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `openai` | ✅ Verified when configured | ✅ Registered | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `groq` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `mistral` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `together` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `atlascloud` | ✅ Verified when configured | ✅ Registered | ✅ Registered | ⚠️ Unverified | ⚠️ Unverified |
|
||||
|
||||
## Running a focused check
|
||||
|
||||
Use `-providers` to select a provider and `-harnesses` to narrow the scenario:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-providers openai,anthropic \
|
||||
-harnesses agent-flow,plan-delegate
|
||||
```
|
||||
|
||||
By default missing live-provider keys are reported as skips. Add
|
||||
`-require-configured` in CI when a selected provider must be present:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-providers openai \
|
||||
-require-configured
|
||||
```
|
||||
|
||||
The command also prints the registered model, image, and video provider
|
||||
capabilities before running conformance. Disable that with `-capabilities=false`
|
||||
when you only want pass/fail output.
|
||||
|
||||
For automation, add `-summary-json` to capture the selected providers,
|
||||
harnesses, registered capability rows, and pass/skip/fail results in a stable
|
||||
machine-readable file. Add `-capabilities-markdown` when you also want a
|
||||
ready-to-publish Markdown support table for release notes, docs, or issue
|
||||
updates:
|
||||
|
||||
```sh
|
||||
go run ./internal/harness/provider-conformance \
|
||||
-providers mock \
|
||||
-summary-json provider-conformance-summary.json \
|
||||
-capabilities-markdown provider-capabilities.md
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- [The Agent Harness](agent-harness.html)
|
||||
- [Agents and Workflows](agents-and-workflows.html)
|
||||
- [AI Provider Guide](ai-provider-guide.html)
|
||||
- [Roadmap](/docs/roadmap.html)
|
||||
@@ -1,47 +0,0 @@
|
||||
package guides_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
func TestAIProviderGuideCapabilityMatrixMatchesRegistry(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
|
||||
guidePath := filepath.Join(filepath.Dir(filename), "ai-provider-guide.md")
|
||||
b, err := os.ReadFile(guidePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read AI provider guide: %v", err)
|
||||
}
|
||||
guide := string(b)
|
||||
|
||||
for _, row := range ai.CapabilityRows() {
|
||||
want := fmt.Sprintf("| `%s` | %s | %s | %s | %s |", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
|
||||
if !strings.Contains(guide, want) {
|
||||
t.Fatalf("AI provider guide capability matrix is stale; missing row %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func yesNo(ok bool) string {
|
||||
if ok {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
@@ -42,10 +42,10 @@ Because every endpoint is already an MCP tool, the gateway is where you charge.
|
||||
|
||||
```bash
|
||||
micro mcp serve --address :3000 \
|
||||
--x402_pay_to 0xYourAddress \
|
||||
--x402_network solana \
|
||||
--x402_amount 10000 \
|
||||
--x402_facilitator https://facilitator.example
|
||||
--x402-pay-to 0xYourAddress \
|
||||
--x402-network solana \
|
||||
--x402-amount 10000 \
|
||||
--x402-facilitator https://facilitator.example
|
||||
```
|
||||
|
||||
## A shoppable catalog
|
||||
@@ -69,7 +69,7 @@ Free tools carry no `payment` block. This is the foundation for a tool marketpla
|
||||
Different tools can cost different amounts. Pricing is an **operator** concern — the payTo address is the operator's, and amounts change without redeploying anyone's service — so it's configured at the gateway with a file, the same way per-tool scopes and rate limits are. Point the gateway at an x402 config:
|
||||
|
||||
```bash
|
||||
micro mcp serve --address :3000 --x402_config x402.json
|
||||
micro mcp serve --address :3000 --x402-config x402.json
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -85,7 +85,7 @@ micro mcp serve --address :3000 --x402_config x402.json
|
||||
}
|
||||
```
|
||||
|
||||
`amount` is the default (here `"0"` — free unless priced), and `amounts` sets per-tool overrides keyed by tool name. There is no "pricing" abstraction; it's the x402 `amount`, resolved per tool, in the protocol's own vocabulary. `micro mcp serve` accepts the file via `--x402_config`; the standalone gateway accepts the same file via `--x402-config` or the `X402_CONFIG` environment variable.
|
||||
`amount` is the default (here `"0"` — free unless priced), and `amounts` sets per-tool overrides keyed by tool name. There is no "pricing" abstraction; it's the x402 `amount`, resolved per tool, in the protocol's own vocabulary. The standalone gateway accepts the same file via `--x402-config` or the `X402_CONFIG` environment variable.
|
||||
|
||||
## Paying for tools (the consumer side)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ about the framework.
|
||||
- [Transport](transport.html)
|
||||
- [Store](store.html)
|
||||
- [Plugins](plugins.html)
|
||||
- [Examples](examples/)
|
||||
- [Examples](examples/index.md)
|
||||
|
||||
## Development & Deployment
|
||||
|
||||
@@ -52,9 +52,9 @@ about the framework.
|
||||
## Advanced
|
||||
|
||||
- [Framework Comparison](guides/comparison.html)
|
||||
- [Architecture Decisions](architecture/)
|
||||
- [Real-World Examples](examples/realworld/)
|
||||
- [Migration Guides](guides/migration/)
|
||||
- [Architecture Decisions](architecture/index.md)
|
||||
- [Real-World Examples](examples/realworld/index.md)
|
||||
- [Migration Guides](guides/migration/index.md)
|
||||
- [Observability](observability.html)
|
||||
- [Contributing](contributing.html)
|
||||
- [Roadmap](roadmap.html)
|
||||
|
||||
@@ -98,7 +98,7 @@ Choose based on your deployment:
|
||||
import "go-micro.dev/v6/server/grpc"
|
||||
|
||||
// Use gRPC for better performance
|
||||
service := micro.NewService("performance-example",
|
||||
service := micro.NewService(
|
||||
micro.Server(grpc.NewServer()),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
|
||||
func main() {
|
||||
reg := consul.NewConsulRegistry()
|
||||
svc := micro.NewService("plugin-example",
|
||||
svc := micro.NewService(
|
||||
micro.Registry(reg),
|
||||
)
|
||||
svc.Init()
|
||||
@@ -43,7 +43,7 @@ import (
|
||||
|
||||
func main() {
|
||||
reg := etcd.NewRegistry()
|
||||
svc := micro.NewService("plugin-example", micro.Registry(reg))
|
||||
svc := micro.NewService(micro.Registry(reg))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := bnats.NewNatsBroker()
|
||||
svc := micro.NewService("plugin-example", micro.Broker(b))
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -75,7 +75,7 @@ import (
|
||||
|
||||
func main() {
|
||||
b := rabbitmq.NewBroker()
|
||||
svc := micro.NewService("plugin-example", micro.Broker(b))
|
||||
svc := micro.NewService(micro.Broker(b))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -90,7 +90,7 @@ import (
|
||||
|
||||
func main() {
|
||||
t := tnats.NewTransport()
|
||||
svc := micro.NewService("plugin-example", micro.Transport(t))
|
||||
svc := micro.NewService(micro.Transport(t))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -108,7 +108,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
svc := micro.NewService("plugin-example",
|
||||
svc := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
)
|
||||
@@ -130,7 +130,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := postgres.NewStore()
|
||||
svc := micro.NewService("plugin-example", micro.Store(st))
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -145,7 +145,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := natsjskv.NewStore()
|
||||
svc := micro.NewService("plugin-example", micro.Store(st))
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import (
|
||||
|
||||
func main() {
|
||||
reg := consul.NewRegistry()
|
||||
service := micro.NewService("registry-example",
|
||||
service := micro.NewService(
|
||||
micro.Registry(reg),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("store-example")
|
||||
service := micro.NewService()
|
||||
service.Init()
|
||||
|
||||
// Write a record
|
||||
@@ -64,7 +64,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := postgres.NewStore()
|
||||
svc := micro.NewService("store-example", micro.Store(st))
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
@@ -79,7 +79,7 @@ import (
|
||||
|
||||
func main() {
|
||||
st := natsjskv.NewStore()
|
||||
svc := micro.NewService("store-example", micro.Store(st))
|
||||
svc := micro.NewService(micro.Store(st))
|
||||
svc.Init()
|
||||
svc.Run()
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@ import (
|
||||
grpcClient "go-micro.dev/v6/client/grpc"
|
||||
)
|
||||
|
||||
service := micro.NewService("myservice",
|
||||
// Important: Server must be specified before Name
|
||||
service := micro.NewService(
|
||||
micro.Server(grpcServer.NewServer()),
|
||||
micro.Client(grpcClient.NewClient()),
|
||||
micro.Name("myservice"),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -57,7 +59,7 @@ import (
|
||||
|
||||
func main() {
|
||||
t := grpc.NewTransport()
|
||||
service := micro.NewService("transport-example",
|
||||
service := micro.NewService(
|
||||
micro.Transport(t),
|
||||
)
|
||||
service.Init()
|
||||
@@ -74,7 +76,7 @@ import (
|
||||
|
||||
func main() {
|
||||
t := tnats.NewTransport()
|
||||
service := micro.NewService("transport-example", micro.Transport(t))
|
||||
service := micro.NewService(micro.Transport(t))
|
||||
service.Init()
|
||||
service.Run()
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@
|
||||
|
||||
<section class="section-alt">
|
||||
<div class="section">
|
||||
<h2>Features</h2>
|
||||
<p class="subtitle">An agent harness and a service framework in one — agents, services, and flows on the same runtime, with the production pieces agents need.</p>
|
||||
<h2>The Runtime Around the Agent</h2>
|
||||
<p class="subtitle">An agent harness and a service framework in one — agents, services, and flows on the same runtime, with the production pieces agents need once they leave the demo.</p>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<strong>Agent Harness</strong>
|
||||
@@ -247,7 +247,7 @@
|
||||
<div class="two-col">
|
||||
<div class="two-col-text">
|
||||
<h2>Developer Experience</h2>
|
||||
<p><code>micro new</code> scaffolds a service or an agent — every endpoint is automatically an MCP tool. <code>micro run</code> starts everything with hot reload, an API gateway, and an interactive console, and <code>micro chat</code> talks to your agents and services from the terminal.</p>
|
||||
<p><code>micro run</code> starts your services with hot reload, an API gateway, and an interactive console for talking to them. <code>micro deploy</code> pushes to production via SSH.</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src="/images/generated/developer-experience.jpg" alt="Terminal showing micro run and micro chat" />
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"go-micro.dev/v6/server"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
type serviceKey struct{}
|
||||
@@ -202,9 +201,6 @@ func FlowWithCheckpoint(c Checkpoint) FlowOption { return flow.WithCheckpoint(c)
|
||||
// are always kept). Default: retain all.
|
||||
func FlowDeleteOnSuccess() FlowOption { return flow.DeleteOnSuccess() }
|
||||
|
||||
// FlowTraceProvider enables OpenTelemetry spans for stepped flow runs and steps.
|
||||
func FlowTraceProvider(tp trace.TracerProvider) FlowOption { return flow.TraceProvider(tp) }
|
||||
|
||||
// FlowCall is a step action: an RPC to a service endpoint, sending the
|
||||
// state data as the request and storing the response.
|
||||
func FlowCall(service, endpoint string) FlowStepFunc { return flow.Call(service, endpoint) }
|
||||
|
||||
Reference in New Issue
Block a user