Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99e5e76de1 | |||
| 3a9455f28c | |||
| 05e53ec38c | |||
| dbdd0e1d6f | |||
| 8146e29f7e | |||
| c110774dc7 | |||
| c0a5775fb5 | |||
| f844a23bb2 | |||
| 04e8759d41 | |||
| 78725135aa | |||
| 2ff64ff0b2 | |||
| 7a8e7cd9ae | |||
| b58eed1698 | |||
| c92c9cc244 | |||
| a2bf43e9ef | |||
| 49bac7e4a8 | |||
| 5aac7e3ca0 | |||
| d86585bf5c |
@@ -12,7 +12,7 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
providers:
|
||||
@@ -67,6 +67,11 @@ jobs:
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
# Atlas Cloud's default chat model was failing the agent/tool-use
|
||||
# conformance harnesses; run it against a stronger tool-use model.
|
||||
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
|
||||
# catalog id differs (Atlas uses org/model ids).
|
||||
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
|
||||
run: |
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
|
||||
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
|
||||
|
||||
@@ -65,7 +65,7 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
```
|
||||
|
||||
This scaffold → run → call path is covered by the no-secret CI harness. To run
|
||||
the same local contract (including the 0→hero services → agents → workflows path,
|
||||
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
|
||||
chat/inspect CLI boundaries, and deploy dry-run), use:
|
||||
|
||||
```bash
|
||||
@@ -401,8 +401,8 @@ Swap providers with a single import — same interface everywhere:
|
||||
| Google Gemini | `gemini-2.5-flash` |
|
||||
| Groq | `llama-3.3-70b-versatile` |
|
||||
| Mistral | `mistral-large-latest` |
|
||||
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `llama-3.3-70b` |
|
||||
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic", ai.WithAPIKey(key))
|
||||
@@ -423,6 +423,7 @@ See [all examples](examples/README.md).
|
||||
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [AI Integration](internal/website/docs/ai-integration.md)
|
||||
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
|
||||
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
|
||||
- [Agent Design](internal/docs/AGENT_DESIGN.md)
|
||||
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
|
||||
|
||||
@@ -179,6 +179,8 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
|
||||
a.mem = NewInMemory(a.opts.HistoryLimit)
|
||||
case a.opts.MemoryCompaction.MaxMessages > 0:
|
||||
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
|
||||
case a.opts.MemoryRetrievalLimit > 0:
|
||||
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
|
||||
default:
|
||||
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
codecBytes "go-micro.dev/v6/codec/bytes"
|
||||
@@ -121,6 +122,7 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
// so the result runs plan → step → loop → approve → checkpoint → base.
|
||||
h := a.baseHandler()
|
||||
h = a.toolTimeoutWrap(h)
|
||||
h = a.toolRetryWrap(h)
|
||||
h = a.checkpointToolWrap(h)
|
||||
h = a.approveWrap(h)
|
||||
h = a.loopWrap(h)
|
||||
@@ -164,6 +166,99 @@ func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// toolRetryWrap retries transient tool failures with bounded backoff. It is
|
||||
// opt-in because tools can have side effects; guardrail refusals and caller
|
||||
// cancellation are never retried.
|
||||
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
maxAttempts := a.opts.ToolMaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
var res ai.ToolResult
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errResult(call.ID, err.Error())
|
||||
}
|
||||
res = next(ctx, call)
|
||||
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
|
||||
return annotateToolAttempts(res, attempt)
|
||||
}
|
||||
|
||||
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !t.Stop() {
|
||||
<-t.C
|
||||
}
|
||||
return errResult(call.ID, ctx.Err().Error())
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return annotateToolAttempts(res, maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func retryableToolResult(res ai.ToolResult) bool {
|
||||
if res.Refused != "" {
|
||||
return false
|
||||
}
|
||||
msg := toolErrorMessage(res)
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return ai.IsTransientError(fmt.Errorf("%s", msg))
|
||||
}
|
||||
|
||||
func toolErrorMessage(res ai.ToolResult) string {
|
||||
if m, ok := res.Value.(map[string]string); ok {
|
||||
return m["error"]
|
||||
}
|
||||
if m, ok := res.Value.(map[string]any); ok {
|
||||
if v, ok := m["error"].(string); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
var decoded map[string]string
|
||||
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
|
||||
return decoded["error"]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
|
||||
if attempts <= 1 {
|
||||
return res
|
||||
}
|
||||
res.Attempts = attempts
|
||||
if m, ok := res.Value.(map[string]string); ok {
|
||||
cp := map[string]any{}
|
||||
for k, v := range m {
|
||||
cp[k] = v
|
||||
}
|
||||
cp["attempts"] = attempts
|
||||
res.Value = cp
|
||||
if b, err := json.Marshal(cp); err == nil {
|
||||
res.Content = string(b)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
|
||||
if base <= 0 {
|
||||
base = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
base <<= shift
|
||||
}
|
||||
if base > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// baseHandler executes a tool call: a developer custom tool, the built-in
|
||||
// delegate, or an RPC to the service. It is the innermost handler.
|
||||
func (a *agentImpl) baseHandler() ai.ToolHandler {
|
||||
@@ -338,6 +433,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
|
||||
ModelCallTimeout(a.opts.ModelTimeout),
|
||||
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
|
||||
ToolCallTimeout(a.opts.ToolTimeout),
|
||||
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
|
||||
TraceProvider(a.opts.TraceProvider),
|
||||
)
|
||||
// Record lineage so the sub-agent's tool calls carry this run as parent.
|
||||
|
||||
@@ -102,6 +102,75 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
toolRuns := 0
|
||||
modelCalls := 0
|
||||
failFirst := true
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
modelCalls++
|
||||
if opts.ToolHandler != nil {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
|
||||
if res.Content != "provisioned" {
|
||||
t.Fatalf("tool result = %q, want provisioned", res.Content)
|
||||
}
|
||||
}
|
||||
if failFirst {
|
||||
failFirst = false
|
||||
return nil, errors.New("process stopped after tool checkpoint")
|
||||
}
|
||||
return &ai.Response{Reply: "resumed after restart"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
newAgent := func() *agentImpl {
|
||||
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "provisioned", nil
|
||||
}))
|
||||
}
|
||||
|
||||
first := newAgent()
|
||||
_, err := first.Ask(ctx, "provision api")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want simulated process stop")
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
|
||||
}
|
||||
runs, err := Pending(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("Pending before restart: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
|
||||
}
|
||||
|
||||
restarted := newAgent()
|
||||
resp, err := Resume(ctx, restarted, runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume after restart: %v", err)
|
||||
}
|
||||
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
|
||||
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
|
||||
}
|
||||
if modelCalls != 2 {
|
||||
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, runs[0].ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
|
||||
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := store.NewMemoryStore()
|
||||
|
||||
+25
-6
@@ -56,6 +56,16 @@ func NewMemory(s store.Store, key string, limit int) Memory {
|
||||
return m
|
||||
}
|
||||
|
||||
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
|
||||
// conversation and archives every turn for retrieval. It is useful when callers
|
||||
// want relevant durable recall without summary compaction in the active context.
|
||||
// A nil store or empty key keeps only the active in-process buffer.
|
||||
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
|
||||
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
|
||||
m.load()
|
||||
return m
|
||||
}
|
||||
|
||||
// NewCompactingMemory returns store-backed memory with explicit compaction and
|
||||
// retrieval controls. It keeps all messages in the backing store, compacts older
|
||||
// turns into a deterministic summary when the conversation exceeds maxMessages,
|
||||
@@ -100,16 +110,20 @@ func NewInMemory(limit int) Memory {
|
||||
// storeMemory is the default Memory: an ai.History buffer optionally
|
||||
// persisted to a store.
|
||||
type storeMemory struct {
|
||||
mu sync.Mutex
|
||||
store store.Store
|
||||
key string
|
||||
hist *ai.History
|
||||
compaction MemoryCompaction
|
||||
archive []ai.Message
|
||||
mu sync.Mutex
|
||||
store store.Store
|
||||
key string
|
||||
hist *ai.History
|
||||
compaction MemoryCompaction
|
||||
archive []ai.Message
|
||||
retrieveAll bool
|
||||
}
|
||||
|
||||
func (m *storeMemory) Add(role, content string) {
|
||||
m.mu.Lock()
|
||||
if m.retrieveAll {
|
||||
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
|
||||
}
|
||||
m.hist.Add(role, content)
|
||||
m.mu.Unlock()
|
||||
m.compact()
|
||||
@@ -133,6 +147,8 @@ func (m *storeMemory) Clear() {
|
||||
// Recall returns archived messages whose content contains words from query.
|
||||
// It is deterministic and provider-neutral: no embeddings or model calls are
|
||||
// required, but semantic/vector stores can replace Memory for richer retrieval.
|
||||
// When created with NewRetrievalMemory the archive contains every persisted
|
||||
// turn; when created with NewCompactingMemory it contains compacted older turns.
|
||||
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -186,6 +202,9 @@ func (m *storeMemory) load() {
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.archive = state.Archive
|
||||
if m.retrieveAll && len(m.archive) == 0 {
|
||||
m.archive = append(m.archive, state.Messages...)
|
||||
}
|
||||
for _, msg := range state.Messages {
|
||||
m.hist.Add(msg.Role, msg.Content)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,49 @@ func TestWithMemoryUsed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta owner is lee")
|
||||
m.Add("assistant", "tracked")
|
||||
m.Add("user", "alpha owner is sam")
|
||||
|
||||
if got := len(m.Messages()); got != 2 {
|
||||
t.Fatalf("active messages = %d, want bounded history of 2", got)
|
||||
}
|
||||
|
||||
recall, ok := m.(MemoryRecall)
|
||||
if !ok {
|
||||
t.Fatal("retrieval memory should support recall")
|
||||
}
|
||||
recalled := recall.Recall("alpha budget", 2)
|
||||
if len(recalled) == 0 {
|
||||
t.Fatal("expected relevant recalled turns")
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
|
||||
st := store.NewMemoryStore()
|
||||
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
|
||||
m.Add("user", "alpha budget is 42")
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
|
||||
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
|
||||
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
t.Fatalf("recalled %d messages, want 1", len(recalled))
|
||||
}
|
||||
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded recall = %q, want alpha budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
|
||||
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
|
||||
writer := m.(Memory)
|
||||
|
||||
@@ -60,10 +60,19 @@ type Options struct {
|
||||
// applied before custom tools, delegate, and service RPC calls so context
|
||||
// deadlines propagate consistently through the agent loop.
|
||||
ToolTimeout time.Duration
|
||||
// ToolMaxAttempts bounds tool execution attempts including the first call.
|
||||
// Default 1; retries are opt-in because tools can have side effects.
|
||||
ToolMaxAttempts int
|
||||
// ToolRetryBackoff is the base delay between transient tool failures.
|
||||
ToolRetryBackoff time.Duration
|
||||
|
||||
// Memory is the agent's conversation memory. Nil = the default
|
||||
// store-backed memory (durable across restarts).
|
||||
Memory Memory
|
||||
// MemoryRetrievalLimit enables retrieval-backed default memory without
|
||||
// compaction. The active conversation stays bounded to this many messages
|
||||
// while every turn is archived for deterministic recall.
|
||||
MemoryRetrievalLimit int
|
||||
// MemoryCompaction enables deterministic compaction/retrieval on the
|
||||
// default store-backed memory. Custom Memory implementations can expose
|
||||
// retrieval by implementing MemoryRecall.
|
||||
@@ -117,6 +126,8 @@ func newOptions(opts ...Option) Options {
|
||||
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
|
||||
ModelRetryBackoff: 100 * time.Millisecond,
|
||||
ToolTimeout: 30 * time.Second,
|
||||
ToolMaxAttempts: 1,
|
||||
ToolRetryBackoff: 100 * time.Millisecond,
|
||||
// On by default and lenient: identical repeated calls are a
|
||||
// no-progress loop, never useful. Set LoopLimit(0) to disable.
|
||||
LoopLimit: 3,
|
||||
@@ -225,6 +236,16 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// ToolRetry sets the tool retry budget and backoff for transient failures.
|
||||
// Attempts include the first call. Retries are opt-in because tools may have
|
||||
// side effects; keep handlers idempotent before enabling this.
|
||||
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolMaxAttempts = maxAttempts
|
||||
o.ToolRetryBackoff = backoff
|
||||
}
|
||||
}
|
||||
|
||||
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
|
||||
// ":4000"), so other agents can reach it directly by URL without a
|
||||
// separate gateway. The agent stays a normal go-micro service as well;
|
||||
@@ -240,6 +261,18 @@ func WithMemory(m Memory) Option {
|
||||
return func(o *Options) { o.Memory = m }
|
||||
}
|
||||
|
||||
// RetrievalMemory enables deterministic, store-backed retrieval memory for
|
||||
// the default agent memory without compaction. Active context is capped at
|
||||
// activeLimit messages while every turn is archived in the store for Recall.
|
||||
func RetrievalMemory(activeLimit int) Option {
|
||||
return func(o *Options) {
|
||||
o.MemoryRetrievalLimit = activeLimit
|
||||
if o.MemoryRecallLimit == 0 {
|
||||
o.MemoryRecallLimit = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CompactMemory enables deterministic, store-backed memory compaction for the
|
||||
// default agent memory. Older turns are summarized once active context exceeds
|
||||
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
|
||||
|
||||
+30
-5
@@ -41,6 +41,10 @@ const (
|
||||
AttrErrorKind = "agent.error.kind"
|
||||
AttrCheckpointStatus = "agent.checkpoint.status"
|
||||
AttrCheckpointStage = "agent.checkpoint.stage"
|
||||
AttrFlowName = "agent.flow.name"
|
||||
AttrFlowStep = "agent.flow.step"
|
||||
AttrDispatch = "agent.dispatch"
|
||||
AttrTrigger = "agent.trigger"
|
||||
)
|
||||
|
||||
type RunEvent struct {
|
||||
@@ -124,8 +128,12 @@ func (a *agentImpl) startRun(ctx context.Context, message string) (context.Conte
|
||||
}
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
|
||||
attrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
}, info)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
|
||||
a.recordSpanEvent(span, runEvent)
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
@@ -171,16 +179,17 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
return resp, err
|
||||
}
|
||||
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
|
||||
attrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
attribute.String(AttrRunID, info.RunID),
|
||||
attribute.String(AttrParentRunID, info.ParentID),
|
||||
attribute.String(AttrAgentName, info.Agent),
|
||||
attribute.String(AttrProvider, provider),
|
||||
attribute.String(AttrModel, model),
|
||||
))
|
||||
}, info)
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
if info.Attempt > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
|
||||
}
|
||||
@@ -360,6 +369,22 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
return attrs
|
||||
}
|
||||
|
||||
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
|
||||
if info.Flow != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
|
||||
}
|
||||
if info.Step != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
|
||||
}
|
||||
if info.Dispatch != "" {
|
||||
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
|
||||
}
|
||||
if info.Trigger != "" {
|
||||
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if e.RunID == "" {
|
||||
return
|
||||
|
||||
@@ -184,3 +184,48 @@ type testStatusError struct {
|
||||
func (e testStatusError) Error() string { return "provider status error" }
|
||||
|
||||
func (e testStatusError) StatusCode() int { return e.code }
|
||||
|
||||
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
a := newTestAgent(
|
||||
Name("tool-retry-success"),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
|
||||
content := toolContent(a.toolHandler(), "flaky", nil)
|
||||
if content != "ok" {
|
||||
t.Fatalf("tool result = %q, want ok", content)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
|
||||
attempts := 0
|
||||
a := newTestAgent(
|
||||
Name("tool-retry-refusal"),
|
||||
MaxSteps(1),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
|
||||
attempts++
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
h := a.toolHandler()
|
||||
_ = toolContent(h, "counted", nil)
|
||||
content := toolContent(h, "counted", nil)
|
||||
if !strings.Contains(content, "step limit reached") {
|
||||
t.Fatalf("tool result = %q, want step-limit refusal", content)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,7 +57,14 @@ func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
if options.Model == "" {
|
||||
options.Model = "deepseek-ai/DeepSeek-V3-0324"
|
||||
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
|
||||
// (e.g. to run CI conformance against a stronger tool-use model) without
|
||||
// a code change; fall back to a sensible default otherwise.
|
||||
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
|
||||
options.Model = m
|
||||
} else {
|
||||
options.Model = "deepseek-ai/DeepSeek-V3-0324"
|
||||
}
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.atlascloud.ai"
|
||||
|
||||
+6
-3
@@ -93,9 +93,10 @@ func (c ToolCall) Scan(v any) error {
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
|
||||
// Refused names the reason a guardrail blocked the call before it ran
|
||||
// ("max_steps", "loop", "approval"); empty when the call executed. A
|
||||
// tool wrapper can switch on it to build reliability tooling — react to
|
||||
@@ -129,6 +130,8 @@ type RunInfo struct {
|
||||
Attempt int // current model Generate attempt, starting at 1 when known
|
||||
MaxAttempts int // configured model Generate attempt budget when known
|
||||
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
|
||||
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
|
||||
Trigger string // external trigger or schedule label that started the run, when known
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -150,6 +151,109 @@ func TestStreamProvidersCloseCancelsInFlightRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamProvidersPropagateProviderErrors(t *testing.T) {
|
||||
for _, provider := range conformingStreamProviders(t) {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "upstream quota exhausted", http.StatusTooManyRequests)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err == nil {
|
||||
_ = stream.Close()
|
||||
t.Fatal("Stream returned nil error for provider failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "upstream quota exhausted") {
|
||||
t.Fatalf("Stream error = %v, want provider status and body", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "test-key") {
|
||||
t.Fatal("provider error leaked API key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamProvidersHonorCanceledContextBeforeRequest(t *testing.T) {
|
||||
for _, provider := range conformingStreamProviders(t) {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
var sawRequest bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawRequest = true
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(ctx, &ai.Request{Prompt: "Hello"})
|
||||
if err == nil {
|
||||
_ = stream.Close()
|
||||
t.Fatal("Stream returned nil error for canceled context")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Stream error = %v, want context.Canceled", err)
|
||||
}
|
||||
if sawRequest {
|
||||
t.Fatal("provider sent request after context was already canceled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provider string
|
||||
keyEnv string
|
||||
modelEnv string
|
||||
}{
|
||||
{provider: "openai", keyEnv: "OPENAI_API_KEY", modelEnv: "OPENAI_MODEL"},
|
||||
{provider: "groq", keyEnv: "GROQ_API_KEY", modelEnv: "GROQ_MODEL"},
|
||||
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
|
||||
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
|
||||
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
key := os.Getenv(tc.keyEnv)
|
||||
if key == "" {
|
||||
t.Skipf("%s not set; skipping configured provider stream check", tc.keyEnv)
|
||||
}
|
||||
|
||||
opts := []ai.Option{ai.WithAPIKey(key)}
|
||||
if model := os.Getenv(tc.modelEnv); model != "" {
|
||||
opts = append(opts, ai.WithModel(model))
|
||||
}
|
||||
stream, err := ai.New(tc.provider, opts...).Stream(context.Background(), &ai.Request{Prompt: "Reply with exactly: ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
deadline := time.After(30 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for provider stream chunk")
|
||||
default:
|
||||
}
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
t.Fatal("provider stream ended without content")
|
||||
}
|
||||
t.Fatalf("Recv returned error: %v", err)
|
||||
}
|
||||
if chunk.Reply != "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"anthropic", "gemini"} {
|
||||
provider := provider
|
||||
|
||||
+6
-2
@@ -142,7 +142,8 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
|
||||
if f.opts.TriggerTopic != "" {
|
||||
sub, err := br.Subscribe(f.opts.TriggerTopic, func(p broker.Event) error {
|
||||
data := string(p.Message().Body)
|
||||
if err := f.Execute(context.Background(), data); err != nil {
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
|
||||
if err := f.Execute(ctx, data); err != nil {
|
||||
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
|
||||
}
|
||||
return nil
|
||||
@@ -223,7 +224,10 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
|
||||
}
|
||||
|
||||
runID := uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.RunID = runID
|
||||
info.Flow = f.name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
|
||||
+21
-4
@@ -26,6 +26,8 @@ const (
|
||||
AttrFlowErrorKind = "flow.error.kind"
|
||||
AttrFlowVerificationStatus = "flow.verification.status"
|
||||
AttrFlowVerificationNote = "flow.verification.note"
|
||||
AttrFlowDispatch = "flow.dispatch"
|
||||
AttrFlowTrigger = "flow.trigger"
|
||||
)
|
||||
|
||||
func (f *Flow) tracer() trace.Tracer {
|
||||
@@ -36,12 +38,15 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
|
||||
if f.opts.TraceProvider == nil {
|
||||
return ctx, func(Run, error) {}
|
||||
}
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrFlowRunID, run.ID),
|
||||
attribute.String(AttrFlowParentID, run.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStatus, run.Status),
|
||||
))
|
||||
}
|
||||
attrs = appendRunInfoDispatch(attrs, info)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
|
||||
start := time.Now()
|
||||
return ctx, func(done Run, err error) {
|
||||
span.SetAttributes(
|
||||
@@ -64,12 +69,14 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
|
||||
return f.runStep(ctx, step, in)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String(AttrFlowRunID, info.RunID),
|
||||
attribute.String(AttrFlowParentID, info.ParentID),
|
||||
attribute.String(AttrFlowName, f.name),
|
||||
attribute.String(AttrFlowStepName, step.Name),
|
||||
))
|
||||
}
|
||||
attrs = appendRunInfoDispatch(attrs, info)
|
||||
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(attrs...))
|
||||
start := time.Now()
|
||||
out, attempts, verification, err := f.runStep(ctx, step, in)
|
||||
span.SetAttributes(
|
||||
@@ -95,3 +102,13 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
|
||||
span.End()
|
||||
return out, attempts, verification, err
|
||||
}
|
||||
|
||||
func appendRunInfoDispatch(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
|
||||
if info.Dispatch != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowDispatch, info.Dispatch))
|
||||
}
|
||||
if info.Trigger != "" {
|
||||
attrs = append(attrs, attribute.String(AttrFlowTrigger, info.Trigger))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
@@ -74,3 +74,29 @@ func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
func withTestRunInfo(ctx context.Context, runID string) context.Context {
|
||||
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
|
||||
}
|
||||
|
||||
func TestScheduledFlowOpenTelemetryDispatchAttributes(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
|
||||
step := Step{Name: "summarize", Run: func(ctx context.Context, in State) (State, error) {
|
||||
in.Data = []byte("queued")
|
||||
return in, nil
|
||||
}}
|
||||
f := New("scheduled-observed", Trigger("schedule.daily"), WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "scheduled-observed")), TraceProvider(tp), Steps(step))
|
||||
if err := Scheduled(f, "daily ops review").Tick(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, span := range exp.GetSpans().Snapshots() {
|
||||
if span.Name() != spanNameFlowRun {
|
||||
continue
|
||||
}
|
||||
attrs := flowSpanAttributes(span.Attributes())
|
||||
if attrs[AttrFlowDispatch] != "schedule" || attrs[AttrFlowTrigger] != "schedule.daily" {
|
||||
t.Fatalf("scheduled run span dispatch attributes = %#v", attrs)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("flow run span not emitted")
|
||||
}
|
||||
|
||||
+14
-1
@@ -3,6 +3,8 @@ package flow
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// Schedule binds a flow to a recurring work item without introducing a
|
||||
@@ -25,7 +27,18 @@ func Scheduled(f *Flow, data string) Schedule {
|
||||
|
||||
// Tick starts one scheduled run immediately and returns when that run finishes.
|
||||
func (s Schedule) Tick(ctx context.Context) error {
|
||||
return s.flow.Execute(ctx, s.data)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.Dispatch = "schedule"
|
||||
if info.Trigger == "" {
|
||||
info.Trigger = s.flow.opts.TriggerTopic
|
||||
}
|
||||
if info.Trigger == "" {
|
||||
info.Trigger = "schedule"
|
||||
}
|
||||
return s.flow.Execute(ai.WithRunInfo(ctx, info), s.data)
|
||||
}
|
||||
|
||||
// RunEvery drives scheduled runs from a ticker until ctx is canceled. It does
|
||||
|
||||
+6
-1
@@ -433,7 +433,12 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
steps := f.opts.Steps
|
||||
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
info.RunID = run.ID
|
||||
info.ParentID = run.ParentID
|
||||
info.Agent = f.name
|
||||
info.Flow = f.name
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
ctx, finishSpan := f.startRunSpan(ctx, run)
|
||||
var spanErr error
|
||||
defer func() { finishSpan(run, spanErr) }()
|
||||
|
||||
@@ -21,11 +21,8 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Add a scheduled agent run harness contract** ([#3486](https://github.com/micro/go-micro/issues/3486)) — with conformance, failure/cancellation hardening, checkpoint resume, memory summarization, and A2A continuity now shipped, the highest-value remaining gap is proving unattended agents as first-class operational-harness workloads. This should compose existing services, agents, flows, store-backed memory, verification, conformance, and run inspection so the scaffold → run → chat → inspect lifecycle extends to scheduled/looping work without adding a hosted scheduler, graph DSL, or breaking public APIs.
|
||||
|
||||
2. **Export agent `RunInfo` as OpenTelemetry spans** ([#3501](https://github.com/micro/go-micro/issues/3501)) — once agents can run unattended, operators need production-grade visibility that lines up with the existing tracing stack. Map run lifecycle, checkpoints/resume, tool/delegate steps, and terminal failure/cancellation metadata into OpenTelemetry rather than creating a separate observability surface.
|
||||
|
||||
3. **Broaden provider-backed `ai.Stream` conformance** ([#3502](https://github.com/micro/go-micro/issues/3502)) — A2A/chat streaming is now a visible UX seam, but the trust story depends on every provider adapter behaving consistently for streaming deltas, cancellation, and errors. Keep this as a conformance extension with mock/no-secret coverage plus provider-gated checks, so interop hardening stays CI-verifiable.
|
||||
1. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — cross-provider conformance now has scheduled CI, env-selectable AtlasCloud coverage, and phase-labelled pass/skip/fail output, so the highest-value remaining Now-phase gap is predictable failure semantics. Tool retries are in place, but the lifecycle still needs cancellation/deadline propagation across agent runs, model calls, tool calls, plan/delegate, and flow handoffs so services → agents → workflows fail safely instead of becoming opaque loops.
|
||||
2. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, and hardened provider conformance. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures. This keeps `micro runs` useful while making the harness observable in the systems developers already run.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -20,7 +20,11 @@ agent test and the harnesses in `internal/harness`:
|
||||
preserving run metadata.
|
||||
|
||||
The command also emits the registered provider capability matrix so the run shows
|
||||
which providers advertise model, image, video, and streaming support.
|
||||
which providers advertise model, image, video, and streaming support. Console output
|
||||
and summary artifacts label each harness with the phase it is proving (for example,
|
||||
model call + tool call, workflow event + tool call, or streaming fallback + tool
|
||||
call), so provider failures identify the failed lifecycle phase instead of only the
|
||||
provider name.
|
||||
|
||||
## Local usage
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ import (
|
||||
|
||||
const defaultHarnesses = "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
|
||||
|
||||
var harnessPhases = map[string]string{
|
||||
"agent": "model call + tool call",
|
||||
"universe": "service discovery + tool call",
|
||||
"agent-flow": "workflow event + tool call",
|
||||
"plan-delegate": "plan persistence + delegation + tool call",
|
||||
"a2a-stream-fallback": "streaming fallback + tool call",
|
||||
}
|
||||
|
||||
var providerEnv = map[string]string{
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
@@ -92,15 +100,16 @@ func main() {
|
||||
}
|
||||
|
||||
for _, harness := range harnesses {
|
||||
fmt.Printf("\n==> %s / %s\n", provider, harness)
|
||||
phase := harnessPhase(harness)
|
||||
fmt.Printf("\n==> %s / %s (%s)\n", provider, harness, phase)
|
||||
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
|
||||
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
|
||||
fmt.Printf("FAIL %s / %s (%s): %v\n", provider, harness, phase, err)
|
||||
failed++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusFailed, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
|
||||
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusPassed})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +149,7 @@ const (
|
||||
type conformanceResult struct {
|
||||
Provider string `json:"provider"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -176,14 +186,18 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
|
||||
b.WriteString("## Capability matrix\n\n")
|
||||
b.WriteString(capabilityMarkdown(summary.Capabilities))
|
||||
b.WriteString("\n## Harness results\n\n")
|
||||
b.WriteString("| Provider | Harness | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- |\n")
|
||||
b.WriteString("| Provider | Harness | Phase | Status | Detail |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
for _, result := range summary.Results {
|
||||
harness := result.Harness
|
||||
if harness == "" {
|
||||
harness = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
|
||||
phase := result.Phase
|
||||
if phase == "" {
|
||||
phase = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", result.Provider, harness, markdownCell(phase), result.Status, markdownCell(result.Error))
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
@@ -209,6 +223,13 @@ func markdownList(values []string) string {
|
||||
return strings.Join(escaped, ", ")
|
||||
}
|
||||
|
||||
func harnessPhase(harness string) string {
|
||||
if phase, ok := harnessPhases[harness]; ok {
|
||||
return phase
|
||||
}
|
||||
return "harness"
|
||||
}
|
||||
|
||||
func markdownCell(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestWriteSummaryMarkdown(t *testing.T) {
|
||||
summary := conformanceSummary{
|
||||
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
|
||||
Results: []conformanceResult{
|
||||
{Provider: "mock", Harness: "agent-flow", Status: statusPassed},
|
||||
{Provider: "mock", Harness: "agent-flow", Phase: harnessPhase("agent-flow"), Status: statusPassed},
|
||||
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
|
||||
},
|
||||
Passed: 1,
|
||||
@@ -120,8 +120,8 @@ func TestWriteSummaryMarkdown(t *testing.T) {
|
||||
"Providers: —.",
|
||||
"Harnesses: —.",
|
||||
"| mock | ✅ | — | — | — |",
|
||||
"| mock | agent-flow | passed | — |",
|
||||
"| live | — | skipped | missing \\| key |",
|
||||
"| mock | agent-flow | workflow event + tool call | passed | — |",
|
||||
"| live | — | — | skipped | missing \\| key |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("summary markdown = %q, want %q", got, want)
|
||||
@@ -129,6 +129,15 @@ func TestWriteSummaryMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarnessPhaseLabelsKnownHarnesses(t *testing.T) {
|
||||
if got := harnessPhase("a2a-stream-fallback"); got != "streaming fallback + tool call" {
|
||||
t.Fatalf("harnessPhase() = %q, want streaming fallback phase", got)
|
||||
}
|
||||
if got := harnessPhase("custom"); got != "harness" {
|
||||
t.Fatalf("harnessPhase(custom) = %q, want fallback phase", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownListEscapesBackticks(t *testing.T) {
|
||||
got := markdownList([]string{"agent", "bad`name"})
|
||||
want := "`agent`, `bad\\`name`"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package zerotoheroci
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
|
||||
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"))
|
||||
for _, want := range []string{
|
||||
"make harness",
|
||||
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
|
||||
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
|
||||
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
|
||||
"./internal/harness/zero-to-hero-ci/run.sh",
|
||||
"go run ./internal/harness/agent-flow",
|
||||
"make provider-conformance-mock",
|
||||
"internal/harness/plan-delegate",
|
||||
"internal/harness/universe",
|
||||
} {
|
||||
if !strings.Contains(guide, want) {
|
||||
t.Fatalf("0→hero guide missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
readme := readFile(t, filepath.Join(root, "README.md"))
|
||||
if !strings.Contains(readme, "internal/website/docs/guides/zero-to-hero.md") {
|
||||
t.Fatal("README does not point to the canonical 0→hero guide")
|
||||
}
|
||||
|
||||
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
|
||||
if !strings.Contains(nav, "0→hero Reference") || !strings.Contains(nav, "/docs/guides/zero-to-hero.html") {
|
||||
t.Fatal("website navigation does not expose the canonical 0→hero guide")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -32,6 +32,8 @@ examples:
|
||||
- title: Real-World Examples
|
||||
url: /docs/examples/realworld/
|
||||
guides:
|
||||
- title: 0→hero Reference
|
||||
url: /docs/guides/zero-to-hero.html
|
||||
- title: The Agent Harness
|
||||
url: /docs/guides/agent-harness.html
|
||||
- title: Agents and Workflows
|
||||
@@ -70,6 +72,7 @@ project:
|
||||
- title: Server (optional)
|
||||
url: /docs/server.html
|
||||
search_order:
|
||||
- /docs/guides/zero-to-hero.html
|
||||
- /docs/getting-started.html
|
||||
- /docs/mcp.html
|
||||
- /docs/architecture.html
|
||||
|
||||
@@ -135,8 +135,8 @@ The store backend determines durability — file-backed by default, Postgres or
|
||||
| **What** | Capability | Intelligence | Event orchestration |
|
||||
| **Does** | Handles requests | Manages services | Reacts to events |
|
||||
| **Knows** | Its endpoints | Its services' endpoints | Its trigger topic |
|
||||
| **State** | Store | Store (memory) | Stateless per event |
|
||||
| **Create** | `micro.NewService()` | `micro.NewAgent()` | `micro.NewFlow()` |
|
||||
| **State** | Store | Store-backed memory | Checkpointed run history |
|
||||
| **Create** | `micro.NewService("name")` | `micro.NewAgent("name")` | `micro.NewFlow("name")` |
|
||||
| **Package** | `service/` | `agent/` | `flow/` |
|
||||
|
||||
They compose:
|
||||
@@ -164,7 +164,7 @@ Or build an agent in Go:
|
||||
```go
|
||||
package main
|
||||
|
||||
import "go-micro.dev/v5"
|
||||
import "go-micro.dev/v6"
|
||||
|
||||
func main() {
|
||||
agent := micro.NewAgent("task-mgr",
|
||||
@@ -176,7 +176,7 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
The agent package is at `go-micro.dev/v5/agent`. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
|
||||
The agent implementation lives under `go-micro.dev/v6/agent`; most users create agents through the top-level `go-micro.dev/v6` API. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ your stack — the harness *is* the stack.
|
||||
| Tools | Every service endpoint is an MCP-callable tool from registry metadata — no extra code | Shipped |
|
||||
| Memory | Store-backed agent memory (`AgentMemory`), durable across restarts | Shipped |
|
||||
| Guardrails | `MaxSteps`, `LoopLimit`, `ApproveTool`, tool wrappers — enforced at the call site | Shipped |
|
||||
| Workflows | Durable flows; `flow.Loop` for run-until-done | Shipped |
|
||||
| Workflows | Durable flows; `micro.FlowLoop` for run-until-done | Shipped |
|
||||
| Planning / delegation | Built-in `plan` and `delegate` tools on every agent | Shipped |
|
||||
| Discovery & RPC | Registry + client; agents and services find and call each other | Shipped |
|
||||
| Interop | MCP (tools), A2A (agents), x402 (paid tools) | Shipped |
|
||||
|
||||
@@ -18,11 +18,11 @@ you're done") has no natural ceiling. So a usable loop needs two things:
|
||||
1. a **stop condition** — how it decides it's done, and
|
||||
2. a **hard cap** — a guardrail that guarantees it always terminates.
|
||||
|
||||
Go Micro gives you both as a flow step: `flow.Loop`.
|
||||
Go Micro gives you both as a flow step: `micro.FlowLoop`.
|
||||
|
||||
## The shape
|
||||
|
||||
`flow.Loop` is a `StepFunc`, so it drops into a flow's ordered, checkpointed
|
||||
`micro.FlowLoop` is a `StepFunc`, so it drops into a flow's ordered, checkpointed
|
||||
step list like any other step. It runs a **body** step repeatedly, carrying the
|
||||
flow `State` from one pass to the next, until a stop condition fires or the
|
||||
iteration cap is hit — whichever comes first.
|
||||
|
||||
@@ -89,14 +89,32 @@ Agents use store-backed conversation memory by default, scoped under the agent's
|
||||
name. That makes short restarts boring: the next `Ask` reloads the retained
|
||||
history from the same store backend you already use for services and flows.
|
||||
Long-running agents can also keep model context bounded without losing useful
|
||||
prior context:
|
||||
prior context. If you want retrieval without summaries, enable bounded active
|
||||
context plus a durable archive of every turn:
|
||||
|
||||
```go
|
||||
a := micro.NewAgent("conductor",
|
||||
micro.AgentServices("task"),
|
||||
micro.AgentProvider("anthropic"),
|
||||
micro.AgentRetrievalMemory(40), // active messages kept in prompt context
|
||||
micro.AgentMemoryRecallLimit(5), // archived turns recalled per Ask
|
||||
)
|
||||
```
|
||||
|
||||
`AgentRetrievalMemory(activeLimit)` switches the default memory to a store-backed
|
||||
retriever. The active conversation is capped at `activeLimit`, every turn is
|
||||
archived in the same scoped store used by the agent, and future asks inject
|
||||
matching archived turns ahead of active context. The built-in ranking is
|
||||
deterministic and credential-free for CI.
|
||||
|
||||
When you also want a rolling summary in active context, use compacting memory:
|
||||
|
||||
```go
|
||||
a := micro.NewAgent("conductor",
|
||||
micro.AgentServices("task"),
|
||||
micro.AgentProvider("anthropic"),
|
||||
micro.AgentCompactMemory(40, 12), // max active messages, recent messages kept verbatim
|
||||
micro.AgentMemoryRecallLimit(5), // archived turns recalled per Ask
|
||||
micro.AgentMemoryRecallLimit(5), // compacted turns recalled per Ask
|
||||
)
|
||||
```
|
||||
|
||||
@@ -105,8 +123,7 @@ deterministic compactor. Once active history grows past `maxMessages`, older
|
||||
turns move into the durable archive, a provider-neutral summary is injected into
|
||||
active context, and the newest `keepRecent` messages stay verbatim. On future
|
||||
asks, archived turns whose text matches the current request are recalled ahead of
|
||||
the active context. The built-in retrieval is intentionally simple and
|
||||
credential-free for CI; teams that need embeddings or a vector database can still
|
||||
the active context. Teams that need embeddings or a vector database can still
|
||||
provide their own `AgentMemory` implementation.
|
||||
|
||||
This is harness memory, not prompt-layer orchestration: services remain the
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# 0→hero reference path
|
||||
|
||||
The 0→hero path is the maintained, no-secret reference for the Go Micro
|
||||
services → agents → workflows lifecycle. It ties the CLI inner loop and the
|
||||
runtime harness together so a contributor can prove the framework still works as
|
||||
one system, not as separate demos.
|
||||
|
||||
Use it when you want to answer: "Can I scaffold a service, run it locally, talk
|
||||
to an agent, inspect durable work, and reach the deployment boundary without
|
||||
cloud credentials?"
|
||||
|
||||
## What the contract covers
|
||||
|
||||
| Boundary | Contract | CI check |
|
||||
| --- | --- | --- |
|
||||
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
|
||||
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Inspect | `micro inspect agent`, `micro inspect flow`, and `micro flow runs` remain discoverable for run history. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
|
||||
| Runtime | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
|
||||
|
||||
## Run the whole no-secret path
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make harness
|
||||
```
|
||||
|
||||
That target runs the scaffold contract, the CLI boundary smoke tests, the
|
||||
0→hero runtime harnesses, the event-driven agent-flow harness, and mock provider
|
||||
conformance. It is intentionally deterministic: no provider key, cloud account,
|
||||
SSH access, or remote service is required.
|
||||
|
||||
## Run focused checks while iterating
|
||||
|
||||
Use the smaller checks when you are working on one seam:
|
||||
|
||||
```sh
|
||||
# Scaffold → run/call contract.
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
|
||||
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
|
||||
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
|
||||
# Durable services → agents → workflows reference scenarios.
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
|
||||
# Event-as-prompt agent flow.
|
||||
go run ./internal/harness/agent-flow
|
||||
|
||||
# Cross-provider semantics with the deterministic mock provider.
|
||||
make provider-conformance-mock
|
||||
```
|
||||
|
||||
## Reference scenarios
|
||||
|
||||
- [`internal/harness/plan-delegate`](https://github.com/micro/go-micro/tree/master/internal/harness/plan-delegate)
|
||||
is the compact 0→hero scenario: real task and notify services, a conductor
|
||||
agent, a comms agent, plan persistence, delegation, and a workflow handoff.
|
||||
- [`internal/harness/universe`](https://github.com/micro/go-micro/tree/master/internal/harness/universe)
|
||||
boots a larger mini-world: inventory, payment, order confirmation, a concierge
|
||||
agent, durable checkpoint/resume, agent run history, flow run history, and A2A
|
||||
reachability.
|
||||
- [`internal/harness/agent-flow`](https://github.com/micro/go-micro/tree/master/internal/harness/agent-flow)
|
||||
shows the event-driven path where a `user.created` event prompts an agent to
|
||||
call services and complete onboarding.
|
||||
|
||||
Together these scenarios keep the North Star executable: services expose typed
|
||||
capabilities, agents use those capabilities with memory and guardrails, and
|
||||
workflows compose the work over time.
|
||||
|
||||
## Keeping the guide honest
|
||||
|
||||
If you change the CLI inner loop, durable flow APIs, agent run history, or the
|
||||
provider/tool semantics, update this guide and the harness in the same PR. The
|
||||
point of 0→hero is not a polished sample app that drifts from reality; it is a
|
||||
CI-verifiable contract that the documented lifecycle still works.
|
||||
@@ -116,6 +116,24 @@ func AgentLoopLimit(n int) AgentOption { return agent.LoopLimit(n) }
|
||||
// each action the agent takes.
|
||||
func AgentApproveTool(fn ApproveFunc) AgentOption { return agent.ApproveTool(fn) }
|
||||
|
||||
// AgentModelCallTimeout sets the timeout for each provider Generate call.
|
||||
func AgentModelCallTimeout(d time.Duration) AgentOption { return agent.ModelCallTimeout(d) }
|
||||
|
||||
// AgentModelRetry sets the provider retry budget and backoff for transient failures.
|
||||
func AgentModelRetry(maxAttempts int, backoff time.Duration) AgentOption {
|
||||
return agent.ModelRetry(maxAttempts, backoff)
|
||||
}
|
||||
|
||||
// AgentToolCallTimeout sets the timeout for each agent tool execution.
|
||||
func AgentToolCallTimeout(d time.Duration) AgentOption { return agent.ToolCallTimeout(d) }
|
||||
|
||||
// AgentToolRetry sets the tool retry budget and backoff for transient failures.
|
||||
// Attempts include the first call. Retries are opt-in because tools can have
|
||||
// side effects; keep handlers idempotent before enabling this.
|
||||
func AgentToolRetry(maxAttempts int, backoff time.Duration) AgentOption {
|
||||
return agent.ToolRetry(maxAttempts, backoff)
|
||||
}
|
||||
|
||||
// Memory is an agent's pluggable conversation memory.
|
||||
type Memory = agent.Memory
|
||||
|
||||
@@ -128,6 +146,12 @@ type ToolFunc = agent.ToolFunc
|
||||
// NewMemory returns the default store-backed agent memory.
|
||||
func NewMemory(s store.Store, key string, limit int) Memory { return agent.NewMemory(s, key, limit) }
|
||||
|
||||
// NewRetrievalMemory returns store-backed memory with bounded active context
|
||||
// and durable retrieval over every prior turn.
|
||||
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
|
||||
return agent.NewRetrievalMemory(s, key, activeLimit)
|
||||
}
|
||||
|
||||
// NewCompactingMemory returns store-backed memory with deterministic
|
||||
// summarization and retrieval controls.
|
||||
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
|
||||
@@ -140,6 +164,10 @@ func NewInMemory(limit int) Memory { return agent.NewInMemory(limit) }
|
||||
// AgentMemory sets the agent's conversation memory (default: store-backed).
|
||||
func AgentMemory(m Memory) AgentOption { return agent.WithMemory(m) }
|
||||
|
||||
// AgentRetrievalMemory enables deterministic default-memory retrieval without
|
||||
// compaction; activeLimit bounds active context while every turn is archived.
|
||||
func AgentRetrievalMemory(activeLimit int) AgentOption { return agent.RetrievalMemory(activeLimit) }
|
||||
|
||||
// AgentCompactMemory enables deterministic default-memory compaction and
|
||||
// retrieval for long-running agents.
|
||||
func AgentCompactMemory(maxMessages, keepRecent int) AgentOption {
|
||||
|
||||
Reference in New Issue
Block a user