Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e2695ed21 | |||
| 5668465859 |
@@ -1,24 +1,21 @@
|
||||
name: Architecture Review
|
||||
|
||||
# Continuous high-altitude oversight of the whole framework and harness — the
|
||||
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
# Where DevRel watches the public story and the increment loop ships code, the
|
||||
# architect watches the SYSTEM and runs alongside the builders: it tracks what is
|
||||
# in flight and what just merged, keeps the roadmap priorities live, and judges
|
||||
# cohesion (harness <-> framework <-> dev UX), missing pieces, and realignment.
|
||||
# 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.
|
||||
#
|
||||
# Its OUTPUT is the ranked queue in internal/docs/PRIORITIES.md plus an assessment
|
||||
# — NOT large refactors. Breaking public-API and architectural changes stay with
|
||||
# the human (see CONTINUOUS_IMPROVEMENT.md).
|
||||
# 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).
|
||||
#
|
||||
# Runs hourly, offset before the increment loop (:29) so it re-prioritizes and
|
||||
# THEN the loop builds the new top of the queue. Opens a fresh issue and
|
||||
# dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
# Opens a fresh issue and dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "59 * * * *" # hourly at :59, just before the :29 increment run (tunable)
|
||||
- cron: "0 8 */3 * *" # roughly every 3 days, 08:00 UTC (tunable)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -44,8 +41,8 @@ jobs:
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Architecture review #$RUN_NUMBER" \
|
||||
--body "Continuous architecture / harness oversight against the North Star in internal/docs/THESIS.md. Output: a re-ranked internal/docs/PRIORITIES.md (only if it changed) plus an assessment.")
|
||||
--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 — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
|
||||
"@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)."
|
||||
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). PICK THE WORK FROM THE QUEUE: read internal/docs/PRIORITIES.md and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. (If PRIORITIES.md is missing or every listed item's issue is already closed, fall back to picking the single highest-value roadmap/issue/improvement-radar item yourself.) Implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this run's tracker>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
"@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."
|
||||
|
||||
@@ -27,8 +27,6 @@ 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
|
||||
@@ -59,19 +57,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 -require-configured
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- "**"
|
||||
jobs:
|
||||
|
||||
@@ -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).
|
||||
|
||||
+6
-56
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/server"
|
||||
"go-micro.dev/v6/store"
|
||||
@@ -169,31 +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)
|
||||
}
|
||||
|
||||
// Resume returns the response for a checkpointed agent run. Completed runs are
|
||||
// returned from the checkpoint without calling the model or replaying tool
|
||||
// calls; failed or in-progress runs continue from the saved input message.
|
||||
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.resume(ctx, runID)
|
||||
}
|
||||
|
||||
// Pending returns checkpointed agent runs that have not completed. It mirrors
|
||||
// flow.Pending for startup recovery loops that drain durable agent work.
|
||||
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
|
||||
}
|
||||
return a.pending(ctx)
|
||||
}
|
||||
|
||||
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -201,10 +175,6 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
a.setup()
|
||||
}
|
||||
|
||||
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
|
||||
}
|
||||
|
||||
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run) (*Response, error) {
|
||||
toolList, err := a.discoverTools()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
@@ -215,16 +185,12 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
a.calls = map[string]int{}
|
||||
|
||||
// Correlate this run's tool calls and surface lineage to wrappers.
|
||||
a.runID = runID
|
||||
a.runID = uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
ParentID: a.parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
run := a.newCheckpointRun(runID, message, parentRunID, existing)
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
defer func() { endRun(err) }()
|
||||
|
||||
@@ -239,10 +205,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
Backoff: a.opts.ModelRetryBackoff,
|
||||
})
|
||||
if err != nil {
|
||||
run.Status = "failed"
|
||||
run.Steps[0].Status = "failed"
|
||||
run.Steps[0].Error = err.Error()
|
||||
_ = a.saveRun(ctx, run)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -261,31 +223,19 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
reply += resp.Answer
|
||||
}
|
||||
|
||||
res := &Response{
|
||||
return &Response{
|
||||
Reply: reply,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
}
|
||||
run.Status = "done"
|
||||
run.State.Stage = ""
|
||||
if b, marshalErr := json.Marshal(res); marshalErr == nil {
|
||||
run.State.Data = b
|
||||
}
|
||||
run.Steps[0].Status = "done"
|
||||
run.Steps[0].Attempts++
|
||||
run.Steps[0].Result = reply
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,29 +60,6 @@ func TestChatResponseIncludesRunIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
const agentAskStep = "ask"
|
||||
|
||||
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
|
||||
now := time.Now()
|
||||
run := flow.Run{
|
||||
ID: runID,
|
||||
ParentID: parentRunID,
|
||||
Flow: a.opts.Name,
|
||||
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
|
||||
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
|
||||
Status: "running",
|
||||
Started: now,
|
||||
Updated: now,
|
||||
}
|
||||
if existing != nil {
|
||||
run = *existing
|
||||
run.Status = "running"
|
||||
run.State.Stage = agentAskStep
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
||||
}
|
||||
run.Steps[0].Status = "in_progress"
|
||||
run.Steps[0].Error = ""
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil
|
||||
}
|
||||
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
|
||||
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
||||
}
|
||||
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent run %s not found", runID)
|
||||
}
|
||||
if run.Status == "done" {
|
||||
var resp Response
|
||||
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
message := string(run.State.Data)
|
||||
parentID := run.ParentID
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
return a.askLocked(ctx, run.ID, message, parentID, &run)
|
||||
}
|
||||
|
||||
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
|
||||
if a.opts.Checkpoint == nil {
|
||||
return nil, nil
|
||||
}
|
||||
runs, err := a.opts.Checkpoint.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := runs[:0]
|
||||
for _, run := range runs {
|
||||
if run.Flow == a.opts.Name && run.Status != "done" {
|
||||
out = append(out, run)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
|
||||
calls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
return &ai.Response{Reply: "done"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("durable-agent"), WithCheckpoint(cp))
|
||||
resp, err := a.Ask(ctx, "finish the work")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("model calls after Ask = %d, want 1", calls)
|
||||
}
|
||||
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
calls++
|
||||
t.Fatal("Resume of a completed run replayed the model")
|
||||
return nil, nil
|
||||
}
|
||||
resumed, err := Resume(ctx, a, resp.RunID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if resumed.Reply != "done" {
|
||||
t.Fatalf("resumed reply = %q, want done", resumed.Reply)
|
||||
}
|
||||
if resumed.RunID != resp.RunID {
|
||||
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("model calls after Resume = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewStore(), "pending-agent")
|
||||
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
a := newTestAgent(Name("pending-agent"), WithCheckpoint(cp))
|
||||
runs, err := Pending(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ID != "run-1" {
|
||||
t.Fatalf("Pending = %#v, want run-1", runs)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -60,9 +59,6 @@ type Options struct {
|
||||
// Memory is the agent's conversation memory. Nil = the default
|
||||
// store-backed memory (durable across restarts).
|
||||
Memory Memory
|
||||
// Checkpoint persists agent Ask runs so callers can resume by run id
|
||||
// after a restart without replaying a run that already completed.
|
||||
Checkpoint flow.Checkpoint
|
||||
|
||||
// MaxSteps bounds the number of tool executions per Ask (0 =
|
||||
// unbounded). Once exceeded, further tool calls are refused and the
|
||||
@@ -215,15 +211,6 @@ func WithMemory(m Memory) Option {
|
||||
return func(o *Options) { o.Memory = m }
|
||||
}
|
||||
|
||||
// WithCheckpoint sets the durability backend for agent Ask runs. The
|
||||
// Checkpoint interface is shared with flow so services, agents, and workflows
|
||||
// can use one execution history backend. When set, each Ask is saved as a
|
||||
// single-step run keyed by run id; Resume returns a completed run's persisted
|
||||
// response instead of calling the model again.
|
||||
func WithCheckpoint(c flow.Checkpoint) Option {
|
||||
return func(o *Options) { o.Checkpoint = c }
|
||||
}
|
||||
|
||||
// WrapTool registers a tool-execution wrapper, the tool-side analog of
|
||||
// a client/server middleware wrapper. Each wrapper takes the next handler
|
||||
// and returns a new one; code before the next(...) call runs before the
|
||||
|
||||
+35
-45
@@ -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,13 +65,6 @@ 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"`
|
||||
@@ -89,7 +80,7 @@ type ChatResponse struct {
|
||||
|
||||
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 +92,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 +105,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 {
|
||||
@@ -164,7 +155,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 +167,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 +180,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,14 +211,13 @@ 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\"\x9e\x01\n" +
|
||||
"\fChatResponse\x12\x14\n" +
|
||||
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
|
||||
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
|
||||
@@ -244,24 +234,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 +262,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,9 +11,6 @@ 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 {
|
||||
|
||||
@@ -196,12 +196,6 @@ The CLI can print the provider capabilities registered in the current build:
|
||||
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
|
||||
|
||||
+8
-32
@@ -5,7 +5,7 @@ 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"`
|
||||
Provider string
|
||||
Capabilities
|
||||
}
|
||||
|
||||
@@ -14,15 +14,11 @@ type CapabilityRow struct {
|
||||
// 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"`
|
||||
Model bool
|
||||
// Image reports whether ai.NewImage can construct an image model provider.
|
||||
Image bool `json:"image"`
|
||||
Image bool
|
||||
// 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"`
|
||||
Video bool
|
||||
}
|
||||
|
||||
// ProviderCapabilities reports the capabilities registered for provider.
|
||||
@@ -30,13 +26,11 @@ 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,
|
||||
Model: hasModel,
|
||||
Image: hasImage,
|
||||
Video: hasVideo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +49,6 @@ func CapabilityMatrix() map[string]Capabilities {
|
||||
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 {
|
||||
@@ -81,17 +72,8 @@ func CapabilityRows() []CapabilityRow {
|
||||
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
|
||||
// kind may be "model", "image", "video", or empty for the union of all
|
||||
// provider registries.
|
||||
func RegisteredProviders(kind string) []string {
|
||||
names := map[string]struct{}{}
|
||||
@@ -109,18 +91,12 @@ func RegisteredProviders(kind string) []string {
|
||||
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":
|
||||
|
||||
@@ -32,12 +32,6 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
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) {
|
||||
@@ -79,17 +73,3 @@ func TestCapabilityMatrix(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -113,17 +113,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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-20
@@ -1,7 +1,6 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -22,34 +21,18 @@ func init() {
|
||||
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",
|
||||
},
|
||||
},
|
||||
Name: "providers",
|
||||
Usage: "Print the registered AI provider capability matrix",
|
||||
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)
|
||||
writeProviderMatrix(c.App.Writer, goai.CapabilityRows())
|
||||
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")
|
||||
|
||||
@@ -2,7 +2,6 @@ package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -29,25 +28,3 @@ func TestWriteProviderMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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)
|
||||
|
||||
+7
-62
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -77,9 +76,6 @@ Examples:
|
||||
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,11 +130,9 @@ 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
|
||||
if c.Bool("pending") {
|
||||
runs = pendingFlowRuns(runs)
|
||||
}
|
||||
runs = filterFlowRuns(runs, opts)
|
||||
if len(runs) == 0 {
|
||||
if c.Bool("pending") {
|
||||
fmt.Printf(" No pending runs recorded for flow %q.\n", name)
|
||||
@@ -150,58 +144,17 @@ func flowRuns(c *cli.Context) error {
|
||||
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 {
|
||||
func pendingFlowRuns(runs []aiflow.Run) []aiflow.Run {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]aiflow.Run, 0, len(runs))
|
||||
pending := make([]aiflow.Run, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
if opts.Pending && run.Status == "done" {
|
||||
continue
|
||||
if run.Status != "done" {
|
||||
pending = append(pending, run)
|
||||
}
|
||||
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})
|
||||
return pending
|
||||
}
|
||||
|
||||
func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
@@ -210,7 +163,6 @@ func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
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 +185,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"}}
|
||||
|
||||
@@ -86,71 +71,3 @@ func TestPendingFlowRunsFiltersCompletedRuns(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+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
-21
@@ -182,19 +182,6 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
|
||||
// registry. In-flight and past runs remain in the store; Stop only ends
|
||||
// the flow's liveness, mirroring how a service leaves the registry when
|
||||
// it shuts down.
|
||||
func (f *Flow) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if f.opts.Timeout <= 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, f.opts.Timeout)
|
||||
}
|
||||
|
||||
func (f *Flow) Stop() error {
|
||||
if f.sub != nil {
|
||||
_ = f.sub.Unsubscribe()
|
||||
@@ -212,18 +199,12 @@ func (f *Flow) Stop() error {
|
||||
// called automatically on each broker event, but can also be
|
||||
// invoked directly for testing or one-shot use.
|
||||
func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Stepped flows run the ordered, checkpointed step loop.
|
||||
if len(f.opts.Steps) > 0 {
|
||||
_, err := f.startRun(ctx, data)
|
||||
return err
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
prompt := data
|
||||
@@ -296,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,11 +1,7 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -87,45 +83,3 @@ func TestDefaultOptions(t *testing.T) {
|
||||
t.Error("default system prompt is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleStepFlowRunInfoIdentifiesFlow(t *testing.T) {
|
||||
model := &runInfoModel{}
|
||||
f := New("single-observed")
|
||||
f.model = model
|
||||
f.toolSet = ai.NewTools(registry.NewMemoryRegistry())
|
||||
|
||||
if err := f.Execute(context.Background(), "observe me"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if model.got.RunID == "" {
|
||||
t.Fatal("RunInfo.RunID is empty")
|
||||
}
|
||||
if model.got.Flow != "single-observed" {
|
||||
t.Fatalf("RunInfo.Flow = %q, want single-observed", model.got.Flow)
|
||||
}
|
||||
if model.got.Agent != "" {
|
||||
t.Fatalf("RunInfo.Agent = %q, want empty for flow-owned LLM run", model.got.Agent)
|
||||
}
|
||||
if model.got.Step != "" {
|
||||
t.Fatalf("RunInfo.Step = %q, want empty for single-step flow", model.got.Step)
|
||||
}
|
||||
}
|
||||
|
||||
type runInfoModel struct {
|
||||
got ai.RunInfo
|
||||
}
|
||||
|
||||
func (m *runInfoModel) Init(...ai.Option) error { return nil }
|
||||
|
||||
func (m *runInfoModel) Options() ai.Options { return ai.Options{} }
|
||||
|
||||
func (m *runInfoModel) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
m.got, _ = ai.RunInfoFrom(ctx)
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
|
||||
func (m *runInfoModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
func (m *runInfoModel) String() string { return "run-info-model" }
|
||||
|
||||
@@ -24,9 +24,6 @@ type Options struct {
|
||||
BaseURL string
|
||||
// HistoryLimit is the max messages per flow execution.
|
||||
HistoryLimit int
|
||||
// Timeout bounds one flow execution when the caller did not already
|
||||
// provide a context deadline. Zero means no flow-level timeout.
|
||||
Timeout time.Duration
|
||||
// OnResult is called after each execution with the result.
|
||||
OnResult func(Result)
|
||||
// Agent, if set, names a registered agent the flow hands each event
|
||||
@@ -97,13 +94,6 @@ func HistoryLimit(n int) Option {
|
||||
return func(o *Options) { o.HistoryLimit = n }
|
||||
}
|
||||
|
||||
// Timeout bounds one flow execution when the caller did not already
|
||||
// provide a context deadline. It applies to broker-triggered runs,
|
||||
// Execute calls, resumed stepped runs, and retry backoff waits.
|
||||
func Timeout(d time.Duration) Option {
|
||||
return func(o *Options) { o.Timeout = d }
|
||||
}
|
||||
|
||||
// OnResult sets a callback for each execution result.
|
||||
func OnResult(fn func(Result)) Option {
|
||||
return func(o *Options) { o.OnResult = fn }
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
spanNameFlowStep = "flow.step"
|
||||
|
||||
AttrFlowRunID = "flow.run.id"
|
||||
AttrFlowParentID = "flow.run.parent_id"
|
||||
AttrFlowName = "flow.name"
|
||||
AttrFlowStepName = "flow.step.name"
|
||||
AttrFlowStatus = "flow.status"
|
||||
@@ -35,7 +34,6 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
|
||||
}
|
||||
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),
|
||||
))
|
||||
@@ -62,7 +60,6 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
|
||||
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),
|
||||
))
|
||||
|
||||
+3
-9
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
@@ -20,8 +19,7 @@ func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
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 {
|
||||
if err := f.Execute(context.Background(), "start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -34,12 +32,12 @@ func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
case spanNameFlowRun:
|
||||
seen[spanNameFlowRun] = true
|
||||
runID = attrs[AttrFlowRunID]
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" {
|
||||
t.Fatalf("run span attributes = %#v", attrs)
|
||||
}
|
||||
case spanNameFlowStep:
|
||||
seen[spanNameFlowStep] = true
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" {
|
||||
t.Fatalf("step span attributes = %#v", attrs)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +68,3 @@ func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withTestRunInfo(ctx context.Context, runID string) context.Context {
|
||||
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
|
||||
}
|
||||
|
||||
+21
-75
@@ -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
|
||||
@@ -241,8 +240,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 +305,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,12 +321,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 {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.checkpoint == nil {
|
||||
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
|
||||
}
|
||||
@@ -363,9 +347,6 @@ func (f *Flow) Resume(ctx context.Context, runID string) error {
|
||||
// stops and returns that run id with the error so callers can log, alert, or
|
||||
// retry later without hiding the failing run.
|
||||
func (f *Flow) ResumePending(ctx context.Context) (string, error) {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
runs, err := f.Pending(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -402,7 +383,7 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, Agent: f.name})
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
@@ -420,10 +401,7 @@ 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)
|
||||
run.Steps[i].Attempts = attempts
|
||||
@@ -432,10 +410,7 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
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
|
||||
@@ -449,22 +424,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))
|
||||
@@ -490,10 +456,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
|
||||
@@ -510,29 +472,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 {
|
||||
|
||||
+2
-152
@@ -99,13 +99,11 @@ func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
return in, nil
|
||||
}}
|
||||
|
||||
mem := store.NewMemoryStore()
|
||||
f := New("correlated",
|
||||
WithCheckpoint(StoreCheckpoint(mem, "correlated")),
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "correlated")),
|
||||
Steps(step),
|
||||
)
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "agent-run-1", Agent: "planner"})
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
if err := f.Execute(context.Background(), "start"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got.Agent != "correlated" {
|
||||
@@ -114,22 +112,6 @@ func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
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) {
|
||||
@@ -279,50 +261,6 @@ func TestFlowStepRetryBackoffWaitsBetweenAttempts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowTimeoutStopsRetryBackoff(t *testing.T) {
|
||||
var attempts int
|
||||
step := Step{Name: "slow", Run: func(_ context.Context, in State) (State, error) {
|
||||
attempts++
|
||||
return in, errors.New("transient")
|
||||
}}
|
||||
|
||||
f := New("timeout-backoff",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "timeout-backoff")),
|
||||
Timeout(20*time.Millisecond),
|
||||
Retry(1),
|
||||
RetryBackoff(time.Hour),
|
||||
Steps(step),
|
||||
)
|
||||
err := f.Execute(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected the timed-out run to fail")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("want a context deadline error, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("timeout should stop during backoff before retrying, got %d attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowTimeoutRespectsExistingDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
|
||||
f := New("existing-deadline", Timeout(time.Millisecond))
|
||||
got, stop := f.withTimeout(ctx)
|
||||
defer stop()
|
||||
|
||||
wantDeadline, _ := ctx.Deadline()
|
||||
gotDeadline, ok := got.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected the existing deadline to remain set")
|
||||
}
|
||||
if !gotDeadline.Equal(wantDeadline) {
|
||||
t.Fatalf("deadline = %v, want existing deadline %v", gotDeadline, wantDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepRetryBackoffStopsOnCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var attempts int
|
||||
@@ -380,41 +318,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) {
|
||||
@@ -479,59 +382,6 @@ func TestStoreCheckpointHonorsCanceledContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+15
-67
@@ -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"},
|
||||
@@ -417,15 +416,13 @@ 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)
|
||||
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})
|
||||
}
|
||||
@@ -436,38 +433,15 @@ type sendParams struct {
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
@@ -483,22 +457,14 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
|
||||
Status: TaskStatus{Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
}
|
||||
if err != nil {
|
||||
reply = "error: " + err.Error()
|
||||
task.Status.State = stateFailed
|
||||
task.Artifacts = []Artifact{textArtifact("error: " + err.Error())}
|
||||
} else {
|
||||
task.Status.State = stateCompleted
|
||||
task.Artifacts = []Artifact{textArtifact(reply)}
|
||||
}
|
||||
task.Artifacts = []Artifact{textArtifact(reply)}
|
||||
task.History = append(task.History, Message{
|
||||
Role: "agent",
|
||||
Parts: []Part{{Kind: "text", Text: reply}},
|
||||
MessageID: uuid.New().String(),
|
||||
TaskID: task.ID,
|
||||
ContextID: task.ContextID,
|
||||
Kind: "message",
|
||||
})
|
||||
d.store(task)
|
||||
return task, nil
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
}
|
||||
|
||||
type getParams struct {
|
||||
@@ -599,24 +565,6 @@ func requestContext(parent context.Context) context.Context {
|
||||
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)
|
||||
|
||||
+2
-50
@@ -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": ""}),
|
||||
)
|
||||
@@ -104,12 +101,6 @@ func TestMessageSendAndGet(t *testing.T) {
|
||||
if len(task.Artifacts) != 1 || textOf(task.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("artifact = %+v, want text 'pong'", task.Artifacts)
|
||||
}
|
||||
if len(task.History) != 2 || task.History[1].Role != "agent" || textOf(task.History[1].Parts) != "pong" {
|
||||
t.Fatalf("history = %+v, want user turn followed by agent reply", task.History)
|
||||
}
|
||||
if task.History[1].TaskID != task.ID || task.History[1].ContextID != task.ContextID {
|
||||
t.Fatalf("agent history linkage = task %q/%q context %q/%q", task.History[1].TaskID, task.ID, task.History[1].ContextID, task.ContextID)
|
||||
}
|
||||
|
||||
got := rpcTask(t, ts.URL+"/agents/echo", `{
|
||||
"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"`+task.ID+`"}}`)
|
||||
@@ -152,45 +143,6 @@ func TestMessageSendUsesRequestContext(t *testing.T) {
|
||||
if len(resp.Result.Artifacts) != 1 || textOf(resp.Result.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("artifact = %+v, want context cancellation", resp.Result.Artifacts)
|
||||
}
|
||||
if len(resp.Result.History) != 2 || resp.Result.History[1].Role != "agent" || textOf(resp.Result.History[1].Parts) != "error: context canceled" {
|
||||
t.Fatalf("history = %+v, want failed agent reply recorded", resp.Result.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStream(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
resp, err := http.Post(ts.URL+"/agents/echo", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
|
||||
var line string
|
||||
if _, err := fmt.Fscan(resp.Body, &line); err != nil {
|
||||
t.Fatalf("read event prefix: %v", err)
|
||||
}
|
||||
if line != "data:" {
|
||||
t.Fatalf("event prefix = %q, want data:", line)
|
||||
}
|
||||
var out struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
if out.Error != nil {
|
||||
t.Fatalf("rpc error: %+v", out.Error)
|
||||
}
|
||||
if out.Result.Status.State != stateCompleted || len(out.Result.Artifacts) != 1 || textOf(out.Result.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("streamed task = %+v", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownMethod(t *testing.T) {
|
||||
@@ -200,9 +152,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,26 +133,16 @@ output) but produce direction and coherence, not just code.
|
||||
factual-alignment and crispness fixes auto-merge like any increment;
|
||||
brand/positioning copy and blog drafts are *surfaced in a report* for the
|
||||
human, never auto-merged.
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/architecture-review.yml`).
|
||||
The *founder lens*, running alongside the builders. Each run it **tracks live
|
||||
state** (what just merged, what's in flight), **prioritizes the roadmap**
|
||||
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
|
||||
coherence and seams, dev-UX friction, missing pieces, drift/realignment), and
|
||||
**maintains the ranked queue** in [`PRIORITIES.md`](PRIORITIES.md) — re-ranking
|
||||
to reflect reality, backing each top item with a scoped issue, and posting an
|
||||
assessment. It runs at `:59`, just before the `:29` increment, so it
|
||||
re-prioritizes and *then* the loop builds the new top. **Its output is the
|
||||
prioritized queue plus the assessment** — it does **not** make breaking or
|
||||
architectural changes itself (those stay with the human). To avoid churn it only
|
||||
opens a PR when the ranking actually changes.
|
||||
- **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).
|
||||
|
||||
The two loops are coupled through `PRIORITIES.md`: the **architect decides *what***
|
||||
(roadmap + internal priorities, ranked, issue-linked) and the **hourly increment
|
||||
loop builds the top open item** — falling back to its own judgment only if the
|
||||
queue is empty. DevRel keeps the public story honest alongside. So work is
|
||||
roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in each
|
||||
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
|
||||
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
|
||||
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.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Priorities
|
||||
|
||||
The ranked work queue for the autonomous improvement loop. The
|
||||
**architecture-review** pass (the *architect*) owns this file: each run it turns
|
||||
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
|
||||
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
|
||||
DX friction) into a single ordered list — highest-value first — and links each
|
||||
item to a tracking issue. The hourly **continuous-improvement** pass works the
|
||||
**top item whose issue is still open**. So the architect decides *what*, and the
|
||||
increment loop *builds* it.
|
||||
|
||||
**Reading / editing.** An item is done when its linked issue closes (the increment
|
||||
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
|
||||
primary ordering; internal findings are interleaved by value, not kept in a
|
||||
separate list. The human can reorder this list — or the issues — at any time to
|
||||
redirect the loop; direction always wins.
|
||||
|
||||
**Off-limits to the loop** (the architect proposes these as notes, never as queue
|
||||
items the loop can auto-merge): brand/positioning copy, breaking public-API
|
||||
changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Now (ranked)
|
||||
|
||||
1. **Streaming end to end** (#3012) — `ai.Stream` through `micro chat`, the agent
|
||||
RPC, and A2A `message/stream`; scope to chat + one provider first. Roadmap →
|
||||
*Next* and now the highest-value lifecycle seam after durable agent resume
|
||||
shipped.
|
||||
2. **Registry disconnection detection** (#2956) — readiness/health when a service
|
||||
silently loses its registry connection. Community-requested production
|
||||
reliability; keeps the service substrate operable because agents depend on
|
||||
discovery.
|
||||
3. **Agent observability spans** (#3182) — export `RunInfo` as OpenTelemetry spans
|
||||
for agent runs, model calls, tool calls, delegation, and failures. Roadmap →
|
||||
*Next*; makes the now-durable harness inspectable in production.
|
||||
4. **Execution lifecycle hooks & metadata** (#2980) — before/after-tool, retry,
|
||||
and failure hooks; first check overlap with the shipped run-timeline /
|
||||
OpenTelemetry work and scope to what's not already covered.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
@@ -4,39 +4,6 @@ This is the North Star for the project and for the autonomous improvement loop
|
||||
(see `CONTINUOUS_IMPROVEMENT.md`). Every change should move toward it; work that
|
||||
doesn't isn't an improvement, however clean.
|
||||
|
||||
## Mission — the problem we solve
|
||||
|
||||
Go Micro started in 2015 because building distributed systems in Go was too hard:
|
||||
too much boilerplate, too many decisions before a single endpoint runs. The
|
||||
mission was to **make building distributed systems simple** — sane defaults,
|
||||
pluggable, out of the developer's way.
|
||||
|
||||
Agents are distributed systems too. The moment an agent discovers services, calls
|
||||
them, holds state, and recovers from failure, it *is* a distributed system — the
|
||||
exact problem Go Micro already solved for services. So the mission hasn't
|
||||
changed, only extended:
|
||||
|
||||
> **Make building agentic, distributed software in Go simple — make building an
|
||||
> agent as easy as building a service, on one runtime, because an agent is a
|
||||
> distributed system.**
|
||||
|
||||
That is the problem we solve, and it is the question every priority is judged
|
||||
against: *does this make the services → agents → workflows lifecycle simpler, more
|
||||
cohesive, and more operable — or is it scope that doesn't serve that?* It is
|
||||
evolution, not a pivot: the decade of services work is the foundation, and the
|
||||
agent layer is that foundation leveraged for the AI era.
|
||||
|
||||
## The canon
|
||||
|
||||
The vision isn't only in this file. The years of focus and context live in the
|
||||
**corpus** — the [blog](../website/blog/) (the actual thinking, e.g. `/blog/14`
|
||||
"Going All In on AI" and `/blog/27` "Back from the Dead"), the
|
||||
[`README`](../../README.md), and the [website](../website/). Those are the canon;
|
||||
this North Star is their **distillation** and must stay faithful to them. When the
|
||||
two diverge, that's a signal — either the work has drifted from the mission, or the
|
||||
North Star has drifted from the lived story and needs re-grounding in the corpus.
|
||||
The architect re-derives alignment from the canon, not from this file alone.
|
||||
|
||||
## Thesis
|
||||
|
||||
Go Micro is an **agent harness and service framework** — one runtime that, holistically,
|
||||
|
||||
@@ -225,19 +225,22 @@ func providerKey(provider string) string {
|
||||
return os.Getenv(env)
|
||||
}
|
||||
|
||||
func runPlanDelegate(provider string) error {
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
apiKey := ""
|
||||
if provider == "mock" {
|
||||
if *provider == "mock" {
|
||||
ai.Register("mock", newMock)
|
||||
} else {
|
||||
apiKey = providerKey(provider)
|
||||
apiKey = providerKey(*provider)
|
||||
if apiKey == "" {
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
|
||||
return nil
|
||||
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n\033[1mPlan & Delegate — live integration harness (provider: %s)\033[0m\n", provider)
|
||||
fmt.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()
|
||||
@@ -248,14 +251,16 @@ func runPlanDelegate(provider string) error {
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
return fmt.Errorf("task handle: %w", err)
|
||||
fmt.Println("task handle:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
return fmt.Errorf("notify handle: %w", err)
|
||||
fmt.Println("notify handle:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go notify.Run()
|
||||
|
||||
@@ -265,7 +270,7 @@ func runPlanDelegate(provider string) error {
|
||||
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.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
go comms.Run()
|
||||
@@ -277,27 +282,24 @@ func runPlanDelegate(provider string) error {
|
||||
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.Provider(*provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
)
|
||||
go conductor.Run()
|
||||
defer conductor.Stop()
|
||||
|
||||
fmt.Println("waiting for services + agents to register...")
|
||||
waitForService := func(name string) error {
|
||||
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 nil
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("service %q never registered", name)
|
||||
}
|
||||
for _, name := range []string{"task", "notify", "comms", "conductor"} {
|
||||
if err := waitForService(name); err != nil {
|
||||
return err
|
||||
}
|
||||
waitForService(name)
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
@@ -305,12 +307,14 @@ func runPlanDelegate(provider string) error {
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
fmt.Println("flow register:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Print("\n\033[1m> flow:\033[0m services + agents + workflow + plan/delegate, no API key.\n\n")
|
||||
if err := f.Execute(context.Background(), "launch readiness"); err != nil {
|
||||
return fmt.Errorf("flow execute: %w", err)
|
||||
fmt.Println("\033[31merror:\033[0m", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
@@ -321,22 +325,13 @@ func runPlanDelegate(provider string) error {
|
||||
if recs, _ := store.Scope(mem, "agent", "conductor").Read("plan"); len(recs) > 0 {
|
||||
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
|
||||
} else {
|
||||
return fmt.Errorf("plan was not persisted")
|
||||
fmt.Println("\n\033[31m! plan was not persisted\033[0m")
|
||||
os.Exit(1)
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
if err := runPlanDelegate(*provider); err != nil {
|
||||
fmt.Println("\033[31merror:\033[0m", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,16 +206,3 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
t.Errorf("notify called %d times, want 1 (flow->agent->delegate->comms chain broken)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroToHeroContract locks the roadmap's second golden path into the
|
||||
// ordinary Go test contract. It runs the same executable harness used by
|
||||
// `make harness`: services + agents + flow + plan/delegate, with only the
|
||||
// LLM replaced by the deterministic mock provider.
|
||||
func TestZeroToHeroContract(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock"); err != nil {
|
||||
t.Fatalf("0→hero harness: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -50,9 +49,6 @@ func main() {
|
||||
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)
|
||||
@@ -65,26 +61,17 @@ func main() {
|
||||
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})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -94,127 +81,23 @@ 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")
|
||||
fmt.Println("provider model image video")
|
||||
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.Printf("%-12s %-5s %-5s %-5s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -55,95 +52,3 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,21 +30,21 @@ 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)
|
||||
fmt.Printf("%s: chat=%t image=%t video=%t\n", row.Provider, row.Model, row.Image, row.Video)
|
||||
}
|
||||
```
|
||||
|
||||
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 |
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) |
|
||||
| --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No |
|
||||
| `atlascloud` | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No |
|
||||
| `groq` | Yes | No | No |
|
||||
| `mistral` | Yes | No | No |
|
||||
| `openai` | Yes | Yes | No |
|
||||
| `together` | Yes | No | No |
|
||||
|
||||
## Step 1: Implement the `ai.Model` Interface
|
||||
|
||||
|
||||
@@ -88,19 +88,6 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user