Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d77eb9f7 | |||
| 07f0d30aea | |||
| 3db9617f43 | |||
| c19e4829ba | |||
| f37ed42bf3 | |||
| 114cd7e19e | |||
| 28fc2ea340 | |||
| 38f5c13e65 | |||
| 27f5e2be52 | |||
| 1fa7584be8 | |||
| e8c632456a | |||
| 9a410cbe8b | |||
| 620374ef22 | |||
| 31556b310e | |||
| 0e67971c53 | |||
| 2af5e6ae8f | |||
| 656c4ea1e7 | |||
| 4a09b4469a | |||
| 25d66f737d | |||
| 45d68c932e | |||
| c5c08e24d8 | |||
| 8a0f4a7636 | |||
| bcf2c92931 | |||
| 57ca94a88b |
@@ -1,21 +1,24 @@
|
||||
name: Architecture Review
|
||||
|
||||
# Periodic high-altitude review of the whole framework and harness against the
|
||||
# North Star (internal/docs/THESIS.md) — part of the autonomous loop
|
||||
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). Where DevRel watches the public
|
||||
# story, the architect watches the system: API coherence, lifecycle gaps, and
|
||||
# whether recent increments are converging on the thesis or sprawling.
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
# 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).
|
||||
#
|
||||
# Opens a fresh issue and dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
# Runs hourly, offset before the increment loop (:29) so it re-prioritizes and
|
||||
# THEN the loop builds the new top of the queue. Opens a fresh issue and
|
||||
# dispatches Codex via CODEX_TRIGGER_TOKEN.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 8 */3 * *" # roughly every 3 days, 08:00 UTC (tunable)
|
||||
- cron: "59 * * * *" # hourly at :59, just before the :29 increment run (tunable)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -41,8 +44,8 @@ jobs:
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Architecture review #$RUN_NUMBER" \
|
||||
--body "Periodic architecture / harness review against the North Star in internal/docs/THESIS.md. Output: an assessment plus scoped follow-up issues for the increment loop.")
|
||||
--body "Continuous architecture / harness oversight against the North Star in internal/docs/THESIS.md. Output: a re-ranked internal/docs/PRIORITIES.md (only if it changed) plus an assessment.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Act as the architect 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)."
|
||||
"@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)."
|
||||
|
||||
@@ -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 single highest-value roadmap/issue/improvement-radar item that advances that thesis, implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body, including 'Closes #$ISSUE_NUM'>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). PICK THE 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."
|
||||
|
||||
+74
-5
@@ -23,6 +23,7 @@ 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"
|
||||
@@ -42,6 +43,7 @@ type Agent interface {
|
||||
Init(...Option)
|
||||
Options() Options
|
||||
Ask(ctx context.Context, message string) (*Response, error)
|
||||
Stream(ctx context.Context, message string) (ai.Stream, error)
|
||||
Run() error
|
||||
Stop() error
|
||||
String() string
|
||||
@@ -171,6 +173,49 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
|
||||
return a.ask(ctx, message, a.parentRunID)
|
||||
}
|
||||
|
||||
// Stream sends a message and returns a streaming model response. Tool-calling
|
||||
// agent runs still use Ask; Stream is for chat turns where immediate token
|
||||
// delivery is more important than tool orchestration.
|
||||
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
toolList, err := a.discoverTools()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
return a.model.Stream(ctx, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -179,6 +224,10 @@ 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)
|
||||
@@ -189,12 +238,16 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
a.calls = map[string]int{}
|
||||
|
||||
// Correlate this run's tool calls and surface lineage to wrappers.
|
||||
a.runID = uuid.New().String()
|
||||
a.runID = runID
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
run := a.newCheckpointRun(runID, message, parentRunID, existing)
|
||||
if err := a.saveRun(ctx, run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
defer func() { endRun(err) }()
|
||||
|
||||
@@ -209,6 +262,10 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
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
|
||||
}
|
||||
|
||||
@@ -227,13 +284,25 @@ func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Resp
|
||||
reply += resp.Answer
|
||||
}
|
||||
|
||||
return &Response{
|
||||
res := &Response{
|
||||
Reply: reply,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
}, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Chat implements the proto AgentHandler interface for RPC.
|
||||
@@ -287,13 +356,13 @@ func (a *agentImpl) Run() error {
|
||||
// Ask in-process — no separate gateway needed to be queried by URL.
|
||||
if a.opts.A2AAddress != "" {
|
||||
card := a2a.Card(a.opts.Name, "http://localhost"+a.opts.A2AAddress, "", a.opts.Services)
|
||||
handler := a2a.NewAgentHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
resp, err := a.Ask(ctx, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
})
|
||||
}, a.Stream)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
|
||||
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type conformanceProvider struct {
|
||||
name string
|
||||
model string
|
||||
key string
|
||||
live bool
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceMatrix(t *testing.T) {
|
||||
providers := []conformanceProvider{
|
||||
{name: "fake"},
|
||||
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
|
||||
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
|
||||
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
|
||||
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
|
||||
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
|
||||
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
|
||||
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
provider := provider
|
||||
t.Run(provider.name, func(t *testing.T) {
|
||||
runAgentConformanceScenario(t, provider)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
t.Helper()
|
||||
if provider.live {
|
||||
if os.Getenv(provider.key) == "" {
|
||||
t.Skipf("%s not set; skipping live %s conformance", provider.key, provider.name)
|
||||
}
|
||||
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
|
||||
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s conformance", provider.name)
|
||||
}
|
||||
} else {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if req.Prompt == "" {
|
||||
return nil, errors.New("missing prompt")
|
||||
}
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
return nil, errors.New("missing tools")
|
||||
}
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("empty tool result")
|
||||
}
|
||||
return &ai.Response{
|
||||
Reply: "used conformance_echo",
|
||||
Answer: res.Content,
|
||||
ToolCalls: []ai.ToolCall{{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: res.Content}},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
}
|
||||
|
||||
var sawTool bool
|
||||
var sawRunInfo bool
|
||||
agentOpts := []Option{
|
||||
Name("conformance-" + provider.name),
|
||||
Provider(provider.name),
|
||||
APIKey(os.Getenv(provider.key)),
|
||||
Prompt("You are a conformance test agent. Use the conformance_echo tool exactly once with input {\"value\":\"agent-conformance\"}, then answer with the tool result."),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(8)),
|
||||
ModelCallTimeout(45 * time.Second),
|
||||
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
|
||||
"value": map[string]any{"type": "string", "description": "value to echo"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
return "", errors.New("missing run info")
|
||||
}
|
||||
if info.RunID == "" || info.Agent != "conformance-"+provider.name {
|
||||
return "", fmt.Errorf("unexpected run info: %+v", info)
|
||||
}
|
||||
sawRunInfo = true
|
||||
if input["value"] != "agent-conformance" {
|
||||
return "", fmt.Errorf("unexpected value %v", input["value"])
|
||||
}
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
}
|
||||
if provider.model != "" {
|
||||
if model := os.Getenv(provider.model); model != "" {
|
||||
agentOpts = append(agentOpts, Model(model))
|
||||
}
|
||||
}
|
||||
|
||||
a := New(agentOpts...)
|
||||
resp, err := a.Ask(context.Background(), "Run the provider conformance check.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.RunID == "" {
|
||||
t.Fatal("RunID is empty")
|
||||
}
|
||||
if resp.Agent != "conformance-"+provider.name {
|
||||
t.Fatalf("Agent = %q", resp.Agent)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("provider did not request the conformance tool")
|
||||
}
|
||||
if !sawRunInfo {
|
||||
t.Fatal("tool did not receive RunInfo")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
|
||||
t.Fatalf("reply %q does not include conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, errors.New("conformance provider failure")
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := New(
|
||||
Name("conformance-error"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
)
|
||||
_, err := a.Ask(context.Background(), "fail deterministically")
|
||||
if err == nil || !strings.Contains(err.Error(), "conformance provider failure") {
|
||||
t.Fatalf("Ask error = %v, want conformance provider failure", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -59,6 +60,9 @@ 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
|
||||
@@ -211,6 +215,15 @@ 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
|
||||
|
||||
+59
-2
@@ -3,6 +3,8 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +30,11 @@ func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOptio
|
||||
}
|
||||
func (m *otelTestModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
|
||||
if strings.Contains(req.Prompt, "delegate") {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-delegate", Name: toolDelegate, Input: map[string]any{"task": "subtask"}})
|
||||
} else if !strings.Contains(req.Prompt, "subtask") {
|
||||
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "done", Usage: ai.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}}, nil
|
||||
}
|
||||
@@ -112,11 +118,62 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
out := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
out[string(attr.Key)] = attr.Value.AsString()
|
||||
out[string(attr.Key)] = fmt.Sprint(attr.Value.AsInterface())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("conductor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
|
||||
if _, err := a.Ask(context.Background(), "delegate please"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spans := exp.GetSpans().Snapshots()
|
||||
var parentRunID string
|
||||
var delegateSpanID string
|
||||
var subRunSeen bool
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor" {
|
||||
parentRunID = attrs[AttrRunID]
|
||||
}
|
||||
}
|
||||
if parentRunID == "" {
|
||||
t.Fatal("parent run span missing run id")
|
||||
}
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameToolCall && attrs[AttrToolName] == toolDelegate {
|
||||
if attrs[AttrDelegate] != "true" || attrs[AttrRunID] != parentRunID {
|
||||
t.Fatalf("delegate span missing correlation attributes: %#v", attrs)
|
||||
}
|
||||
delegateSpanID = s.SpanContext().SpanID().String()
|
||||
}
|
||||
}
|
||||
if delegateSpanID == "" {
|
||||
t.Fatal("delegate tool span not emitted")
|
||||
}
|
||||
for _, s := range spans {
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor.sub" {
|
||||
if attrs[AttrParentRunID] != parentRunID {
|
||||
t.Fatalf("sub-agent run parent attr = %q, want %q", attrs[AttrParentRunID], parentRunID)
|
||||
}
|
||||
if s.Parent().SpanID().String() != delegateSpanID {
|
||||
t.Fatalf("sub-agent run parent span = %s, want delegate span %s", s.Parent().SpanID(), delegateSpanID)
|
||||
}
|
||||
subRunSeen = true
|
||||
}
|
||||
}
|
||||
if !subRunSeen {
|
||||
t.Fatalf("sub-agent run span not emitted; got %d spans", len(spans))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{}
|
||||
want = []string{"openai"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true}},
|
||||
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
|
||||
{Provider: "together", Capabilities: ai.Capabilities{Model: true}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -69,7 +69,7 @@ func TestCapabilityMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true}) {
|
||||
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
|
||||
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true}) {
|
||||
@@ -88,7 +88,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"test-stream"}
|
||||
want := []string{"openai", "test-stream"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
+8
-3
@@ -113,12 +113,17 @@ 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. Per-call detail (tool name, id) is on the ToolCall;
|
||||
// step and attempt counts are naturally counted by the wrapper itself.
|
||||
// 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.
|
||||
type RunInfo struct {
|
||||
RunID string // correlation id for this agent run (one per Ask)
|
||||
RunID string // correlation id for this agent or flow run
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
Flow string // the flow's name, when the call is part of a workflow
|
||||
Step string // the flow step currently executing, when known
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
+83
-2
@@ -2,6 +2,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -20,6 +21,7 @@ func init() {
|
||||
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("openai")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
@@ -140,9 +142,88 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
// Stream generates a streaming response from the OpenAI chat completions API.
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: openai provider", ai.ErrStreamingUnsupported)
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
}
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
return &openAIStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type openAIStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *openAIStream) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta.Content == "" {
|
||||
continue
|
||||
}
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *openAIStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
|
||||
@@ -2,7 +2,11 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -81,16 +85,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-2
@@ -10,7 +10,9 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -79,6 +81,7 @@ Examples:
|
||||
&cli.StringFlag{Name: "model", Usage: "Model name (uses provider default if unset)", EnvVars: []string{"MICRO_AI_MODEL"}},
|
||||
&cli.StringFlag{Name: "base_url", Usage: "Override the provider's base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
|
||||
&cli.StringFlag{Name: "prompt", Usage: "Send a single prompt and exit (non-interactive)"},
|
||||
&cli.BoolFlag{Name: "stream", Usage: "Stream model output as it is generated when the provider supports it"},
|
||||
},
|
||||
Action: run,
|
||||
})
|
||||
@@ -102,6 +105,7 @@ type session struct {
|
||||
sysPrompt string
|
||||
procs []*exec.Cmd
|
||||
agents map[string]agentInfo
|
||||
stream bool
|
||||
|
||||
// Built-in agent capabilities (plan, delegate), shared with the
|
||||
// agent package so the direct-service fallback has the same tools a
|
||||
@@ -311,6 +315,7 @@ func run(c *cli.Context) error {
|
||||
modelName := c.String("model")
|
||||
baseURL := c.String("base_url")
|
||||
singlePrompt := c.String("prompt")
|
||||
streamOutput := c.Bool("stream")
|
||||
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
@@ -347,6 +352,7 @@ func run(c *cli.Context) error {
|
||||
hist: ai.NewHistory(50),
|
||||
builtinTools: builtinTools,
|
||||
builtinHandle: builtinHandle,
|
||||
stream: streamOutput,
|
||||
}
|
||||
s.refreshTools()
|
||||
|
||||
@@ -449,12 +455,21 @@ func (s *session) ask(ctx context.Context, prompt string) error {
|
||||
// Fallback: direct service access (no agents)
|
||||
s.hist.Add("user", prompt)
|
||||
|
||||
resp, err := s.model.Generate(ctx, &ai.Request{
|
||||
req := &ai.Request{
|
||||
Prompt: prompt,
|
||||
SystemPrompt: s.sysPrompt,
|
||||
Tools: s.toolList,
|
||||
Messages: s.hist.Messages(),
|
||||
})
|
||||
}
|
||||
if s.stream {
|
||||
if err := s.askStream(ctx, req); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.model.Generate(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -489,6 +504,34 @@ func (s *session) ask(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) askStream(ctx context.Context, req *ai.Request) error {
|
||||
stream, err := s.model.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stream.Close()
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Print(chunk.Reply)
|
||||
reply.WriteString(chunk.Reply)
|
||||
}
|
||||
if reply.Len() > 0 {
|
||||
fmt.Println()
|
||||
s.hist.Add("assistant", reply.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeToAgent dispatches a message to the right agent.
|
||||
// If there's only one agent, sends directly. Otherwise uses the
|
||||
// LLM to classify intent and route.
|
||||
|
||||
@@ -19,6 +19,45 @@ 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")
|
||||
}
|
||||
@@ -39,35 +78,44 @@ func TestZeroToOneContract(t *testing.T) {
|
||||
defer os.Chdir(oldwd)
|
||||
|
||||
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
|
||||
if err := set.Parse([]string{"helloworld"}); err != nil {
|
||||
set.Bool("no-mcp", false, "")
|
||||
set.Bool("proto", false, "")
|
||||
set.String("template", "", "")
|
||||
set.String("prompt", "", "")
|
||||
set.String("provider", "", "")
|
||||
set.String("api_key", "", "")
|
||||
if err := set.Parse(append(args, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := cli.NewContext(cli.NewApp(), set, nil)
|
||||
|
||||
if err := Run(ctx); err != nil {
|
||||
t.Fatalf("micro new helloworld: %v", err)
|
||||
t.Fatalf("micro new %s %s: %v", strings.Join(args, " "), name, err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
modPath := filepath.Join(serviceDir, "go.mod")
|
||||
func (g generatedService) replaceModule(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
modPath := filepath.Join(g.dir, "go.mod")
|
||||
mod, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
|
||||
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g generatedService) build(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = serviceDir
|
||||
cmd.Dir = g.dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
|
||||
|
||||
+17
-1
@@ -182,6 +182,19 @@ 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()
|
||||
@@ -199,6 +212,9 @@ 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)
|
||||
@@ -206,7 +222,7 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: f.name})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -83,3 +87,45 @@ 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,6 +24,9 @@ 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
|
||||
@@ -94,6 +97,13 @@ 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,6 +17,7 @@ const (
|
||||
spanNameFlowStep = "flow.step"
|
||||
|
||||
AttrFlowRunID = "flow.run.id"
|
||||
AttrFlowParentID = "flow.run.parent_id"
|
||||
AttrFlowName = "flow.name"
|
||||
AttrFlowStepName = "flow.step.name"
|
||||
AttrFlowStatus = "flow.status"
|
||||
@@ -34,6 +35,7 @@ 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),
|
||||
))
|
||||
@@ -60,6 +62,7 @@ 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),
|
||||
))
|
||||
|
||||
+9
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
@@ -19,7 +20,8 @@ func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
return in, nil
|
||||
}}
|
||||
f := New("observed", WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "observed")), TraceProvider(tp), Steps(step))
|
||||
if err := f.Execute(context.Background(), "start"); err != nil {
|
||||
ctx := withTestRunInfo(context.Background(), "agent-run-otel")
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -32,12 +34,12 @@ func TestFlowOpenTelemetrySpans(t *testing.T) {
|
||||
case spanNameFlowRun:
|
||||
seen[spanNameFlowRun] = true
|
||||
runID = attrs[AttrFlowRunID]
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" {
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("run span attributes = %#v", attrs)
|
||||
}
|
||||
case spanNameFlowStep:
|
||||
seen[spanNameFlowStep] = true
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" {
|
||||
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" || attrs[AttrFlowParentID] != "agent-run-otel" {
|
||||
t.Fatalf("step span attributes = %#v", attrs)
|
||||
}
|
||||
}
|
||||
@@ -68,3 +70,7 @@ 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"})
|
||||
}
|
||||
|
||||
+29
-13
@@ -74,13 +74,14 @@ 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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Checkpoint persists and restores flow runs so a run survives a crash
|
||||
@@ -309,12 +310,17 @@ 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(),
|
||||
Flow: f.name,
|
||||
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
|
||||
Status: "running",
|
||||
Started: time.Now(),
|
||||
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(),
|
||||
}
|
||||
for _, s := range f.opts.Steps {
|
||||
run.Steps = append(run.Steps, StepRecord{Name: s.Name, Status: "pending"})
|
||||
@@ -325,6 +331,9 @@ 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
|
||||
}
|
||||
@@ -354,6 +363,9 @@ 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
|
||||
@@ -390,7 +402,7 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, Agent: f.name})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
@@ -478,6 +490,10 @@ 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
|
||||
|
||||
+64
-2
@@ -99,11 +99,13 @@ func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
return in, nil
|
||||
}}
|
||||
|
||||
mem := store.NewMemoryStore()
|
||||
f := New("correlated",
|
||||
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "correlated")),
|
||||
WithCheckpoint(StoreCheckpoint(mem, "correlated")),
|
||||
Steps(step),
|
||||
)
|
||||
if err := f.Execute(context.Background(), "start"); err != nil {
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "agent-run-1", Agent: "planner"})
|
||||
if err := f.Execute(ctx, "start"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got.Agent != "correlated" {
|
||||
@@ -112,6 +114,22 @@ 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) {
|
||||
@@ -261,6 +279,50 @@ 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
|
||||
|
||||
+167
-21
@@ -18,16 +18,18 @@
|
||||
// BaseURL: "https://agents.example.com",
|
||||
// })
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -35,6 +37,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
codecbytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -91,6 +94,9 @@ func New(opts Options) *Gateway {
|
||||
// to Agent.Chat (the gateway) or an in-process Ask (an embedded agent).
|
||||
type Invoke func(ctx context.Context, text string) (string, error)
|
||||
|
||||
// StreamInvoke runs an agent for one message and returns streaming output chunks.
|
||||
type StreamInvoke func(ctx context.Context, text string) (ai.Stream, error)
|
||||
|
||||
// NewAgentHandler returns an http.Handler that serves the A2A protocol
|
||||
// for a single agent: its Agent Card at / and /.well-known/agent.json,
|
||||
// and the JSON-RPC endpoint at /. invoke runs the agent. This is what an
|
||||
@@ -106,6 +112,19 @@ func NewAgentHandler(card AgentCard, invoke Invoke) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// NewAgentStreamHandler is like NewAgentHandler, but serves A2A message/stream
|
||||
// by forwarding model chunks as server-sent task updates when stream is non-nil.
|
||||
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke) http.Handler {
|
||||
d := newDispatcher()
|
||||
mux := http.NewServeMux()
|
||||
card.URL = strings.TrimRight(card.URL, "/")
|
||||
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
|
||||
mux.HandleFunc("GET /{$}", serveCard)
|
||||
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
|
||||
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serveWithStream(w, r, invoke, stream) })
|
||||
return mux
|
||||
}
|
||||
|
||||
// Serve creates a gateway and serves it on opts.Address (blocking).
|
||||
func Serve(opts Options) error {
|
||||
g := New(opts)
|
||||
@@ -210,6 +229,7 @@ type Task struct {
|
||||
const (
|
||||
stateCompleted = "completed"
|
||||
stateFailed = "failed"
|
||||
stateWorking = "working"
|
||||
)
|
||||
|
||||
// JSON-RPC envelopes.
|
||||
@@ -314,7 +334,7 @@ func Card(name, url, description string, services []string) AgentCard {
|
||||
URL: url,
|
||||
Version: "1.0.0",
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: Capabilities{Streaming: false, PushNotifications: false},
|
||||
Capabilities: Capabilities{Streaming: true, 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"},
|
||||
@@ -403,6 +423,10 @@ type dispatcher struct {
|
||||
func newDispatcher() *dispatcher { return &dispatcher{tasks: map[string]*Task{}} }
|
||||
|
||||
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
|
||||
d.serveWithStream(w, r, invoke, nil)
|
||||
}
|
||||
|
||||
func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, invoke Invoke, streamInvoke StreamInvoke) {
|
||||
var req rpcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeRPC(w, nil, nil, &rpcError{Code: errParse, Message: "parse error"})
|
||||
@@ -416,13 +440,19 @@ 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":
|
||||
if streamInvoke != nil {
|
||||
d.streamChunks(requestContext(r.Context()), w, req, streamInvoke)
|
||||
return
|
||||
}
|
||||
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 "message/stream", "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "streaming is not supported"})
|
||||
case "tasks/resubscribe":
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "resubscribe is not supported"})
|
||||
default:
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errMethodNotFound, Message: "method not found: " + req.Method})
|
||||
}
|
||||
@@ -433,6 +463,31 @@ 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) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke) {
|
||||
var p sendParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
@@ -443,28 +498,73 @@ func (d *dispatcher) send(ctx context.Context, w http.ResponseWriter, req rpcReq
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"})
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := invoke(ctx, text)
|
||||
stream, err := invoke(ctx, text)
|
||||
if err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInternal, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
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)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
taskID := uuid.New().String()
|
||||
contextID := p.Message.ContextID
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
}
|
||||
task := &Task{
|
||||
ID: uuid.New().String(),
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: []Message{p.Message},
|
||||
Status: TaskStatus{Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateCompleted, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
task := taskFromReplyWithIDs(p.Message, "error: "+err.Error(), stateFailed, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task, Error: &rpcError{Code: errInternal, Message: err.Error()}})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
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"}
|
||||
}
|
||||
text := textOf(p.Message.Parts)
|
||||
if text == "" {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "message has no text part"}
|
||||
}
|
||||
|
||||
reply, err := invoke(ctx, text)
|
||||
state := stateCompleted
|
||||
if err != nil {
|
||||
task.Status.State = stateFailed
|
||||
task.Artifacts = []Artifact{textArtifact("error: " + err.Error())}
|
||||
} else {
|
||||
task.Status.State = stateCompleted
|
||||
task.Artifacts = []Artifact{textArtifact(reply)}
|
||||
reply = "error: " + err.Error()
|
||||
state = stateFailed
|
||||
}
|
||||
task := taskFromReply(p.Message, reply, state)
|
||||
d.store(task)
|
||||
writeRPC(w, req.ID, task, nil)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
type getParams struct {
|
||||
@@ -525,6 +625,34 @@ func (d *dispatcher) store(t *Task) {
|
||||
}
|
||||
}
|
||||
|
||||
func taskFromReply(input Message, reply, state string) *Task {
|
||||
contextID := input.ContextID
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
}
|
||||
return taskFromReplyWithIDs(input, reply, state, uuid.New().String(), contextID)
|
||||
}
|
||||
|
||||
func taskFromReplyWithIDs(input Message, reply, state, taskID, contextID string) *Task {
|
||||
task := &Task{
|
||||
ID: taskID,
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: []Message{input},
|
||||
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
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",
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
func textOf(parts []Part) string {
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
@@ -565,6 +693,24 @@ 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)
|
||||
|
||||
+152
-2
@@ -4,12 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
@@ -44,6 +48,7 @@ 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": ""}),
|
||||
)
|
||||
@@ -101,6 +106,12 @@ 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+`"}}`)
|
||||
@@ -143,6 +154,145 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
type sliceStream struct {
|
||||
chunks []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *sliceStream) Recv() (*ai.Response, error) {
|
||||
if len(s.chunks) == 0 {
|
||||
if s.err != nil {
|
||||
err := s.err
|
||||
s.err = nil
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
next := s.chunks[0]
|
||||
s.chunks = s.chunks[1:]
|
||||
return &ai.Response{Reply: next}, nil
|
||||
}
|
||||
|
||||
func (s *sliceStream) Close() error { return nil }
|
||||
|
||||
func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
d.serveWithStream(rr, req, nil, func(ctx context.Context, text string) (ai.Stream, error) {
|
||||
if text != "ping" {
|
||||
t.Fatalf("stream text = %q, want ping", text)
|
||||
}
|
||||
return &sliceStream{chunks: []string{"po", "ng"}}, nil
|
||||
})
|
||||
|
||||
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %d, want 3; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
for i, event := range events {
|
||||
if event.Error != nil {
|
||||
t.Fatalf("event %d error: %+v", i, event.Error)
|
||||
}
|
||||
if event.Result.ID != events[0].Result.ID || event.Result.ContextID != events[0].Result.ContextID {
|
||||
t.Fatalf("event %d changed task identity: %+v vs %+v", i, event.Result, events[0].Result)
|
||||
}
|
||||
}
|
||||
if events[0].Result.Status.State != stateWorking || textOf(events[0].Result.Artifacts[0].Parts) != "po" {
|
||||
t.Fatalf("first event = %+v, want working po", events[0].Result)
|
||||
}
|
||||
final := events[len(events)-1].Result
|
||||
if final.Status.State != stateCompleted || textOf(final.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("final event = %+v, want completed pong", final)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, final.ID)
|
||||
if got.ID != final.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("stored task = %+v, want final", got)
|
||||
}
|
||||
}
|
||||
|
||||
func rpcTaskFromDispatcher(t *testing.T, d *dispatcher, id string) Task {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"%s"}}`, id)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
rr := httptest.NewRecorder()
|
||||
d.serve(rr, req, nil)
|
||||
var resp struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Result().Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode tasks/get: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("tasks/get error: %+v", resp.Error)
|
||||
}
|
||||
return resp.Result
|
||||
}
|
||||
|
||||
func TestUnknownMethod(t *testing.T) {
|
||||
@@ -152,9 +302,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":"message/stream","params":{}}`, &resp)
|
||||
rpc(t, ts.URL+"/agents/echo", `{"jsonrpc":"2.0","id":1,"method":"tasks/resubscribe","params":{}}`, &resp)
|
||||
if resp.Error == nil || resp.Error.Code != errMethodNotFound {
|
||||
t.Errorf("expected method-not-found for streaming, got %+v", resp.Error)
|
||||
t.Errorf("expected method-not-found for resubscribe, got %+v", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-2
@@ -314,7 +314,8 @@ func DNSCheck(host string) CheckFunc {
|
||||
//
|
||||
// The lookup runs under the check's timeout, so an unreachable registry
|
||||
// (for example an etcd that has gone away) is reported as down rather
|
||||
// than blocking the probe.
|
||||
// than blocking the probe. Registries that honor ListContext also receive
|
||||
// the check context directly.
|
||||
func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if reg == nil {
|
||||
@@ -322,7 +323,7 @@ func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reg.ListServices()
|
||||
_, err := reg.ListServices(registry.ListContext(ctx))
|
||||
errc <- err
|
||||
}()
|
||||
select {
|
||||
@@ -337,6 +338,54 @@ func RegistryCheck(reg registry.Registry) CheckFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RegistryServiceCheck creates a check that verifies the local service
|
||||
// registration is visible in the registry. RegistryCheck only proves the
|
||||
// registry is reachable; this check also catches the common failure mode
|
||||
// where the process is alive and the registry answers, but this service's
|
||||
// node lease/record has disappeared and other services can no longer
|
||||
// discover it.
|
||||
//
|
||||
// Pass the service name and node ID used during registration. The default
|
||||
// RPC server node ID is service.Options().Name + "-" + service.Options().Id.
|
||||
func RegistryServiceCheck(reg registry.Registry, serviceName, nodeID string) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if reg == nil {
|
||||
return errors.New("no registry configured")
|
||||
}
|
||||
if serviceName == "" {
|
||||
return errors.New("no service name configured")
|
||||
}
|
||||
if nodeID == "" {
|
||||
return errors.New("no service node configured")
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
services, err := reg.GetService(serviceName, registry.GetContext(ctx))
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("registry %s lookup %s failed: %w", reg.String(), serviceName, err)
|
||||
return
|
||||
}
|
||||
for _, service := range services {
|
||||
for _, node := range service.Nodes {
|
||||
if node.Id == nodeID {
|
||||
errc <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
errc <- fmt.Errorf("registry %s missing node %s for service %s", reg.String(), nodeID, serviceName)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("registry %s service check timed out: %w", reg.String(), ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CustomCheck creates a check from any function returning an error
|
||||
func CustomCheck(fn func() error) CheckFunc {
|
||||
return func(ctx context.Context) error {
|
||||
|
||||
@@ -89,3 +89,60 @@ func TestRegistryCheckMarksNotReady(t *testing.T) {
|
||||
t.Error("service should be not-ready when the registry check is down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckHealthy(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
service := ®istry.Service{
|
||||
Name: "orders",
|
||||
Nodes: []*registry.Node{{Id: "orders-1"}},
|
||||
}
|
||||
if err := reg.Register(service); err != nil {
|
||||
t.Fatalf("register service: %v", err)
|
||||
}
|
||||
|
||||
check := RegistryServiceCheck(reg, "orders", "orders-1")
|
||||
if err := check(context.Background()); err != nil {
|
||||
t.Fatalf("registered service node should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMissingNode(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
service := ®istry.Service{
|
||||
Name: "orders",
|
||||
Nodes: []*registry.Node{{Id: "orders-1"}},
|
||||
}
|
||||
if err := reg.Register(service); err != nil {
|
||||
t.Fatalf("register service: %v", err)
|
||||
}
|
||||
|
||||
check := RegistryServiceCheck(reg, "orders", "orders-2")
|
||||
err := check(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("missing service node should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing node orders-2") {
|
||||
t.Errorf("error should describe the missing node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMissingService(t *testing.T) {
|
||||
check := RegistryServiceCheck(registry.NewMemoryRegistry(), "orders", "orders-1")
|
||||
err := check(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("missing service should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), registry.ErrNotFound.Error()) {
|
||||
t.Errorf("error should include registry lookup failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryServiceCheckMarksNotReady(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register("registry-service", RegistryServiceCheck(registry.NewMemoryRegistry(), "orders", "orders-1"))
|
||||
if IsReady(context.Background()) {
|
||||
t.Error("service should be not-ready when its registry node is missing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent provider conformance matrix
|
||||
|
||||
`go test ./...` includes `TestAgentProviderConformanceMatrix`, a shared agent
|
||||
scenario that runs against every registered chat provider. The scenario asks an
|
||||
agent to call a deterministic local tool, verifies the tool receives `ai.RunInfo`,
|
||||
and checks the final response carries the conformance marker. A fake provider path
|
||||
runs on every machine without network access so CI always exercises the harness.
|
||||
|
||||
Live providers are opt-in to avoid flaky unauthenticated PR checks and accidental
|
||||
API spend. To run the live matrix, set `GO_MICRO_AGENT_CONFORMANCE_LIVE=1` plus the
|
||||
provider API keys you want to exercise:
|
||||
|
||||
| Provider | Required API key | Optional model override |
|
||||
| --- | --- | --- |
|
||||
| OpenAI | `OPENAI_API_KEY` | `GO_MICRO_CONFORMANCE_OPENAI_MODEL` |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL` |
|
||||
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
|
||||
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
|
||||
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
|
||||
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
|
||||
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
|
||||
|
||||
When `GO_MICRO_AGENT_CONFORMANCE_LIVE` or a provider key is absent, the live
|
||||
provider subtest reports a deterministic skip. When both are present, a provider
|
||||
failure is a real test failure because drift in chat, tool calling, run metadata,
|
||||
or final-answer behavior means the services → agents lifecycle is no longer
|
||||
consistent across providers.
|
||||
|
||||
The companion `TestAgentProviderConformanceFakeError` keeps provider error
|
||||
propagation covered locally without relying on external credentials.
|
||||
@@ -133,16 +133,26 @@ 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 — 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).
|
||||
- **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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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. **End-to-end agent streaming** (#3200) — complete the roadmap streaming slice:
|
||||
usable `ai.Stream` beyond the current OpenAI path, A2A `message/stream` chunk
|
||||
delivery, cancellation/error semantics, and docs so chat and long-task UX are
|
||||
real across the harness boundary. Roadmap → *Next* streaming, but it now ranks
|
||||
first because durable resume, provider conformance, registry readiness, 0→hero,
|
||||
and agent OpenTelemetry have shipped; streaming is the largest remaining seam
|
||||
in the services → agents → workflows interaction loop.
|
||||
2. **Execution lifecycle hooks & metadata** (#2980) — before/after-tool, retry,
|
||||
and failure hooks; first reconcile the issue with the shipped `AgentWrapTool`,
|
||||
structured refusal reasons, `RunInfo`, and OpenTelemetry spans, then close it or
|
||||
scope only a CI-verifiable gap that wrappers plus tracing cannot express.
|
||||
Roadmap → resilience/operability, but ranked after streaming because most of
|
||||
the originally requested lifecycle metadata now exists and the remaining value
|
||||
is validation/scoping rather than a missing harness primitive.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
@@ -4,6 +4,39 @@ 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,22 +225,19 @@ func providerKey(provider string) string {
|
||||
return os.Getenv(env)
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
func runPlanDelegate(provider string) error {
|
||||
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
|
||||
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("\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()
|
||||
@@ -251,16 +248,14 @@ func main() {
|
||||
taskSvc := new(TaskService)
|
||||
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := task.Handle(taskSvc); err != nil {
|
||||
fmt.Println("task handle:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("task handle: %w", err)
|
||||
}
|
||||
go task.Run()
|
||||
|
||||
notifySvc := new(NotifyService)
|
||||
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
if err := notify.Handle(notifySvc); err != nil {
|
||||
fmt.Println("notify handle:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("notify handle: %w", err)
|
||||
}
|
||||
go notify.Run()
|
||||
|
||||
@@ -270,7 +265,7 @@ func main() {
|
||||
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()
|
||||
@@ -282,24 +277,27 @@ func main() {
|
||||
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) {
|
||||
waitForService := func(name string) error {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("service %q never registered", name)
|
||||
}
|
||||
for _, name := range []string{"task", "notify", "comms", "conductor"} {
|
||||
waitForService(name)
|
||||
if err := waitForService(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
@@ -307,14 +305,12 @@ func main() {
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Println("\033[31merror:\033[0m", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("flow execute: %w", err)
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
@@ -325,13 +321,22 @@ func main() {
|
||||
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 {
|
||||
fmt.Println("\n\033[31m! plan was not persisted\033[0m")
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("plan was not persisted")
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
fmt.Printf("\n\033[31m! unexpected side effects: tasks=%d notify=%d\033[0m\n", taskSvc.count(), notifySvc.count())
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
|
||||
}
|
||||
|
||||
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,3 +206,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,10 @@ yet terminal, it polls `tasks/get` until it completes.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the synchronous JSON-RPC binding:
|
||||
This is the JSON-RPC binding for completed-task execution:
|
||||
|
||||
- **`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.
|
||||
|
||||
@@ -142,11 +143,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):
|
||||
|
||||
- **`message/stream`** (SSE streaming) and `tasks/resubscribe`.
|
||||
- **`tasks/resubscribe`** for reconnecting to a live stream.
|
||||
- Multi-turn `input-required` tasks.
|
||||
- Push notifications.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -37,12 +37,44 @@ your stack — the harness *is* the stack.
|
||||
| Interop | MCP (tools), A2A (agents), x402 (paid tools) | Shipped |
|
||||
| Resilience | Per-call timeout with context propagation; opt-in retry/backoff (`ModelRetry`) across the loop | Shipped |
|
||||
| Durable runs | Checkpoint and resume an agent run (flows already do) | In progress |
|
||||
| Observability | `RunInfo` → OpenTelemetry spans; run history on the CLI | In progress |
|
||||
| Observability | `RunInfo` → OpenTelemetry spans for runs, model calls, tools, delegation, and failures; persisted run history | Shipped |
|
||||
| Streaming | `ai.Stream` through chat, agent, and A2A | In progress |
|
||||
|
||||
The "in progress" rows are exactly the roadmap's [Now and Next](/docs/roadmap.html),
|
||||
and the work is happening in the open.
|
||||
|
||||
## Observing agent runs
|
||||
|
||||
Pass an OpenTelemetry tracer provider when you construct an agent to turn the
|
||||
agent's `RunInfo` into spans:
|
||||
|
||||
```go
|
||||
agent := micro.NewAgent("conductor",
|
||||
micro.AgentProvider("anthropic"),
|
||||
micro.AgentTraceProvider(otel.GetTracerProvider()),
|
||||
)
|
||||
```
|
||||
|
||||
A traced `Ask` emits a parent `agent.run` span plus child spans for
|
||||
`agent.model.call` and `agent.tool.call`. Delegate tool calls are marked with
|
||||
`agent.delegate=true`; ephemeral sub-agents start their own `agent.run` span with
|
||||
`agent.run.parent_id` set to the delegating run, so a trace shows the hand-off
|
||||
from service-like agent to sub-agent. Failure and refusal outcomes set error
|
||||
status on the relevant span and are also recorded in the persisted run timeline.
|
||||
|
||||
Important span attributes include:
|
||||
|
||||
| Attribute | Meaning |
|
||||
|---|---|
|
||||
| `agent.run.id` | Stable run correlation ID surfaced as `ai.RunInfo.RunID` |
|
||||
| `agent.run.parent_id` | Parent run for delegated sub-agent work |
|
||||
| `agent.name` | Agent that owns the run or call |
|
||||
| `agent.model.provider` / `agent.model.name` | Provider and configured model for model calls |
|
||||
| `agent.tool.name` | Tool invoked by the model |
|
||||
| `agent.delegate` | Whether the tool call is a delegation boundary |
|
||||
| `agent.latency_ms` | Elapsed time for the run/call |
|
||||
| `agent.tokens.*` | Token usage when the provider reports it |
|
||||
|
||||
## Why services are the right substrate
|
||||
|
||||
An agent that does real work needs typed, discoverable, callable capabilities —
|
||||
|
||||
@@ -43,7 +43,7 @@ The built-in providers currently register these capability interfaces:
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | No |
|
||||
| `mistral` | Yes | No | No | No |
|
||||
| `openai` | Yes | Yes | No | No |
|
||||
| `openai` | Yes | Yes | No | Yes |
|
||||
| `together` | Yes | No | No | No |
|
||||
|
||||
## Step 1: Implement the `ai.Model` Interface
|
||||
|
||||
@@ -142,6 +142,10 @@ func AgentWrapTool(w ...ai.ToolWrapper) AgentOption {
|
||||
return agent.WrapTool(w...)
|
||||
}
|
||||
|
||||
// AgentTraceProvider enables OpenTelemetry spans for agent runs, model calls,
|
||||
// tool calls, delegation, and failures.
|
||||
func AgentTraceProvider(tp trace.TracerProvider) AgentOption { return agent.TraceProvider(tp) }
|
||||
|
||||
// NewFlow creates an event-driven LLM orchestration unit.
|
||||
//
|
||||
// f := micro.NewFlow("onboard-user",
|
||||
|
||||
Reference in New Issue
Block a user