chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"go-micro.dev/v6/ai"
)
// AnalyzeOptions configures Analyze.
type AnalyzeOptions struct {
// MaxFeedbackSamples bounds the number of representative grader feedback
// strings retained per candidate. Values <= 0 use a small default.
MaxFeedbackSamples int
}
// AnalyzeOption configures Analyze.
type AnalyzeOption func(*AnalyzeOptions)
// AnalyzeMaxFeedbackSamples sets how many grader feedback examples are kept for
// each candidate in the report.
func AnalyzeMaxFeedbackSamples(n int) AnalyzeOption {
return func(o *AnalyzeOptions) { o.MaxFeedbackSamples = n }
}
// Report is the machine-readable output of Analyze. Candidates are ordered from
// worst to best so an agent, CLI, or human can pick the first improvement to try.
type Report struct {
Candidates []Candidate `json:"candidates"`
}
// Candidate identifies one underperforming flow step and the trace evidence that
// made it worth improving.
type Candidate struct {
Step string `json:"step"`
Metric string `json:"metric"`
Score float64 `json:"score"`
Runs int `json:"runs"`
Failures int `json:"failures"`
PassRate float64 `json:"pass_rate"`
ErrorRate float64 `json:"error_rate"`
AverageRetries float64 `json:"average_retries"`
P50Latency time.Duration `json:"p50_latency"`
P95Latency time.Duration `json:"p95_latency"`
SampleFeedback []string `json:"sample_feedback,omitempty"`
RunIDs []string `json:"run_ids,omitempty"`
}
// Analyze aggregates a bounded window of persisted flow runs and returns ranked
// hill-climbing candidates. It uses the same Run records read by Checkpoint.List:
// failed verification fields in step results drive pass-rate and feedback, step
// status drives error rate, and retry attempts contribute retry pressure. An
// empty window returns an empty report.
func Analyze(runs []Run, opts ...AnalyzeOption) Report {
o := AnalyzeOptions{MaxFeedbackSamples: 3}
for _, opt := range opts {
opt(&o)
}
if o.MaxFeedbackSamples <= 0 {
o.MaxFeedbackSamples = 3
}
stats := map[string]*stepStats{}
for _, run := range runs {
for _, step := range run.Steps {
if step.Name == "" {
continue
}
s := stats[step.Name]
if s == nil {
s = &stepStats{}
stats[step.Name] = s
}
s.runs++
s.runIDs = appendUnique(s.runIDs, run.ID)
if step.Attempts > 1 {
s.retries += step.Attempts - 1
}
if step.Status == "failed" || step.Error != "" {
s.errors++
}
if len(run.Steps) > 0 && !run.Started.IsZero() && !run.Updated.IsZero() {
s.latencies = append(s.latencies, run.Updated.Sub(run.Started)/time.Duration(len(run.Steps)))
}
passed, feedback, ok := verificationFields(step.Result)
if ok {
s.graded++
if !passed {
s.gradeFailures++
if feedback != "" && len(s.feedback) < o.MaxFeedbackSamples {
s.feedback = append(s.feedback, feedback)
}
}
}
}
}
report := Report{}
for step, s := range stats {
if s.runs == 0 {
continue
}
failures := s.errors + s.gradeFailures
passRate := 1.0
if s.graded > 0 {
passRate = float64(s.graded-s.gradeFailures) / float64(s.graded)
} else if s.errors > 0 {
passRate = float64(s.runs-s.errors) / float64(s.runs)
}
errorRate := float64(s.errors) / float64(s.runs)
avgRetries := float64(s.retries) / float64(s.runs)
score := float64(s.gradeFailures)*3 + float64(s.errors)*2 + avgRetries
metric := "pass_rate"
if s.gradeFailures == 0 && s.errors > 0 {
metric = "error_rate"
} else if s.gradeFailures == 0 && s.errors == 0 && s.retries > 0 {
metric = "retry_count"
}
report.Candidates = append(report.Candidates, Candidate{
Step: step, Metric: metric, Score: score, Runs: s.runs, Failures: failures,
PassRate: passRate, ErrorRate: errorRate, AverageRetries: avgRetries,
P50Latency: percentile(s.latencies, 0.50), P95Latency: percentile(s.latencies, 0.95),
SampleFeedback: append([]string(nil), s.feedback...), RunIDs: append([]string(nil), s.runIDs...),
})
}
sort.SliceStable(report.Candidates, func(i, j int) bool {
a, b := report.Candidates[i], report.Candidates[j]
if a.Score == b.Score {
return a.Step < b.Step
}
return a.Score > b.Score
})
return report
}
type stepStats struct {
runs, graded, gradeFailures, errors, retries int
feedback, runIDs []string
latencies []time.Duration
}
// PromptOptimizer proposes prompt improvements for a candidate without mutating
// the source flow. Applying the returned prompt stays explicitly gated by the caller.
type PromptOptimizer struct{ model ai.Model }
// LLMOptimizer returns an optimizer that asks model to revise prompts for
// Analyze candidates. The model is injected so tests and callers can use mocks.
func LLMOptimizer(model ai.Model) *PromptOptimizer { return &PromptOptimizer{model: model} }
// OptimizePrompt asks the model for a revised prompt for candidate using the
// current prompt and trace feedback. It returns only the proposal; it never
// modifies a Flow, Step, or Checkpoint.
func (o *PromptOptimizer) OptimizePrompt(ctx context.Context, candidate Candidate, currentPrompt string) (string, error) {
if o == nil || o.model == nil {
return "", fmt.Errorf("flow: LLMOptimizer requires a model")
}
prompt := fmt.Sprintf("Revise this workflow step prompt to improve the failing step.\nStep: %s\nMetric: %s\nScore: %.2f\nFeedback:\n- %s\n\nCurrent prompt:\n%s\n\nReturn only the revised prompt.", candidate.Step, candidate.Metric, candidate.Score, strings.Join(candidate.SampleFeedback, "\n- "), currentPrompt)
resp, err := o.model.Generate(ctx, &ai.Request{Prompt: prompt})
if err != nil {
return "", err
}
proposal := strings.TrimSpace(resp.Answer)
if proposal == "" {
proposal = strings.TrimSpace(resp.Reply)
}
if proposal == "" {
return "", fmt.Errorf("flow: LLMOptimizer returned an empty prompt")
}
return proposal, nil
}
func verificationFields(result string) (bool, string, bool) {
if result == "" {
return false, "", false
}
var obj map[string]any
if err := json.Unmarshal([]byte(result), &obj); err != nil {
return false, "", false
}
v, ok := obj["verification_passed"].(bool)
if !ok {
return false, "", false
}
fb, _ := obj["verification_feedback"].(string)
return v, fb, true
}
func appendUnique(values []string, value string) []string {
if value == "" {
return values
}
for _, v := range values {
if v == value {
return values
}
}
return append(values, value)
}
func percentile(values []time.Duration, p float64) time.Duration {
if len(values) == 0 {
return 0
}
sorted := append([]time.Duration(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)-1) * p)
return sorted[idx]
}
+89
View File
@@ -0,0 +1,89 @@
package flow
import (
"context"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
)
func TestAnalyzeRanksFailedGraderStepAbovePassingStep(t *testing.T) {
now := time.Now()
runs := []Run{
{ID: "run-1", Started: now, Updated: now.Add(time.Second), Steps: []StepRecord{
{Name: "draft", Status: "done", Attempts: 2, Result: `{"verification_passed":false,"verification_feedback":"cite sources"}`},
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
}},
{ID: "run-2", Started: now, Updated: now.Add(2 * time.Second), Steps: []StepRecord{
{Name: "draft", Status: "done", Attempts: 1, Result: `{"verification_passed":false,"verification_feedback":"too vague"}`},
{Name: "publish", Status: "done", Attempts: 1, Result: `{"verification_passed":true,"verification_feedback":"ok"}`},
}},
}
report := Analyze(runs)
if len(report.Candidates) != 2 {
t.Fatalf("Analyze returned %d candidates, want 2", len(report.Candidates))
}
if got := report.Candidates[0].Step; got != "draft" {
t.Fatalf("top candidate = %q, want draft", got)
}
if report.Candidates[0].PassRate != 0 {
t.Fatalf("draft pass rate = %v, want 0", report.Candidates[0].PassRate)
}
}
func TestAnalyzeCarriesFeedbackSamplesAndRunIDs(t *testing.T) {
report := Analyze([]Run{{ID: "run-9", Steps: []StepRecord{{
Name: "grade", Status: "done", Attempts: 3,
Result: `{"verification_passed":false,"verification_feedback":"include totals"}`,
}}}})
if len(report.Candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(report.Candidates))
}
c := report.Candidates[0]
if len(c.SampleFeedback) != 1 || c.SampleFeedback[0] != "include totals" {
t.Fatalf("feedback = %#v, want include totals", c.SampleFeedback)
}
if len(c.RunIDs) != 1 || c.RunIDs[0] != "run-9" {
t.Fatalf("run ids = %#v, want run-9", c.RunIDs)
}
if c.AverageRetries != 2 {
t.Fatalf("average retries = %v, want 2", c.AverageRetries)
}
}
func TestAnalyzeEmptyWindowReturnsEmptyReport(t *testing.T) {
if got := Analyze(nil); len(got.Candidates) != 0 {
t.Fatalf("empty Analyze candidates = %d, want 0", len(got.Candidates))
}
}
func TestLLMOptimizerReturnsProposalWithoutMutatingFlow(t *testing.T) {
f := New("optimize", Prompt("original prompt"))
before := f.opts.Prompt
optimizer := LLMOptimizer(&optimizerModel{reply: "revised prompt"})
proposal, err := optimizer.OptimizePrompt(context.Background(), Candidate{Step: "draft", Metric: "pass_rate", SampleFeedback: []string{"cite sources"}}, before)
if err != nil {
t.Fatalf("OptimizePrompt returned error: %v", err)
}
if !strings.Contains(proposal, "revised") {
t.Fatalf("proposal = %q, want revised prompt", proposal)
}
if f.opts.Prompt != before {
t.Fatalf("flow prompt mutated to %q, want %q", f.opts.Prompt, before)
}
}
type optimizerModel struct{ reply string }
func (m *optimizerModel) Init(...ai.Option) error { return nil }
func (m *optimizerModel) Options() ai.Options { return ai.Options{} }
func (m *optimizerModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return &ai.Response{Reply: m.reply}, nil
}
func (m *optimizerModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *optimizerModel) String() string { return "optimizer" }
+130
View File
@@ -0,0 +1,130 @@
package flow
import (
"context"
"encoding/json"
"testing"
"go-micro.dev/v6/client"
codecbytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/store"
)
// fakeClient embeds the default client (so NewRequest works) and
// overrides Call with a test-supplied function.
type fakeClient struct {
client.Client
callFn func(req client.Request, rsp interface{}) error
}
func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
return c.callFn(req, rsp)
}
// A flow with Agent set hands the rendered prompt to that agent's
// Agent.Chat endpoint over RPC and records its reply — it does not run
// its own model.
func TestExecuteDispatchesToAgent(t *testing.T) {
f := New("welcome", Agent("comms"), Prompt("welcome {{.Data}}"))
var svc, ep, parentID string
f.client = &fakeClient{
Client: client.DefaultClient,
callFn: func(req client.Request, rsp interface{}) error {
svc, ep = req.Service(), req.Endpoint()
reqFrame := req.Body().(*codecbytes.Frame)
var body map[string]string
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
t.Fatalf("request body: %v", err)
}
parentID = body["parent_id"]
frame := rsp.(*codecbytes.Frame)
frame.Data = []byte(`{"reply":"welcomed bob","agent":"comms"}`)
return nil
},
}
if err := f.Execute(context.Background(), "bob"); err != nil {
t.Fatalf("Execute: %v", err)
}
if svc != "comms" || ep != "Agent.Chat" {
t.Errorf("dispatched to %s.%s, want comms.Agent.Chat", svc, ep)
}
if parentID == "" {
t.Fatal("dispatch request parent_id is empty")
}
results := f.Results()
if len(results) != 1 {
t.Fatalf("got %d results, want 1", len(results))
}
if results[0].Reply != "welcomed bob" {
t.Errorf("result reply = %q, want %q", results[0].Reply, "welcomed bob")
}
if results[0].Prompt != "welcome bob" {
t.Errorf("rendered prompt = %q, want %q", results[0].Prompt, "welcome bob")
}
}
// A caller-owned schedule can trigger an agent workflow without a human chat
// prompt and still leave the normal flow run metadata behind for inspection.
func TestScheduledAgentRunHarnessContract(t *testing.T) {
ctx := context.Background()
cp := StoreCheckpoint(store.NewMemoryStore(), "scheduled-contract")
f := New("scheduled-contract",
Trigger("schedule.daily"),
WithCheckpoint(cp),
Steps(Step{Name: "summarize", Run: Dispatch("ops-agent")}),
)
var parentID string
f.client = &fakeClient{
Client: client.DefaultClient,
callFn: func(req client.Request, rsp interface{}) error {
if req.Service() != "ops-agent" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("dispatched to %s.%s, want ops-agent.Agent.Chat", req.Service(), req.Endpoint())
}
reqFrame := req.Body().(*codecbytes.Frame)
var body map[string]string
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
t.Fatalf("request body: %v", err)
}
parentID = body["parent_id"]
if body["message"] != "run unattended daily ops review" {
t.Fatalf("message = %q, want scheduled payload", body["message"])
}
frame := rsp.(*codecbytes.Frame)
frame.Data = []byte(`{"reply":"review queued","agent":"ops-agent","parent_id":"` + parentID + `"}`)
return nil
},
}
if err := Scheduled(f, "run unattended daily ops review").Tick(ctx); err != nil {
t.Fatalf("scheduled tick: %v", err)
}
if parentID == "" {
t.Fatal("dispatch did not receive the scheduled flow run id as parent_id")
}
runs, err := cp.List(ctx)
if err != nil {
t.Fatalf("list scheduled runs: %v", err)
}
if len(runs) != 1 {
t.Fatalf("got %d runs, want 1", len(runs))
}
run := runs[0]
if run.ID != parentID {
t.Fatalf("run ID = %q, parent_id = %q", run.ID, parentID)
}
if run.Flow != "scheduled-contract" || run.Status != "done" {
t.Fatalf("run = %+v, want scheduled-contract done", run)
}
if got := run.State.String(); got != "review queued" {
t.Fatalf("run result = %q, want agent reply", got)
}
if len(run.Steps) != 1 || run.Steps[0].Name != "summarize" || run.Steps[0].Status != "done" {
t.Fatalf("steps = %+v, want summarize done", run.Steps)
}
}
+45
View File
@@ -0,0 +1,45 @@
package flow_test
import (
"context"
"fmt"
"strings"
"go-micro.dev/v6/flow"
)
func ExampleVerify() {
generate := func(_ context.Context, in flow.State) (flow.State, error) {
if strings.Contains(in.String(), "feedback") {
in.Data = []byte(`{"answer":"include a source"}`)
return in, nil
}
in.Data = []byte(`{"answer":"draft"}`)
return in, nil
}
grader := func(_ context.Context, out flow.State) (bool, string, error) {
return strings.Contains(out.String(), "source"), "add a source", nil
}
out, _ := flow.Verify(generate, grader, flow.VerifyMaxAttempts(2))(context.Background(), flow.State{})
fmt.Println(strings.Contains(out.String(), `"verification_passed":true`))
// Output: true
}
func ExampleAnalyze() {
runs := []flow.Run{{
ID: "run-1",
Steps: []flow.StepRecord{{
Name: "draft",
Status: "done",
Result: `{"verification_passed":false,"verification_feedback":"add a source"}`,
}},
}}
report := flow.Analyze(runs)
fmt.Println(report.Candidates[0].Step)
fmt.Println(report.Candidates[0].SampleFeedback[0])
// Output:
// draft
// add a source
}
+345
View File
@@ -0,0 +1,345 @@
// Package flow provides event-driven workflows for go-micro services.
//
// A Flow is a workflow in the sense of Anthropic's "Building Effective
// Agents": LLMs and tools orchestrated through a predefined path. It
// subscribes to a broker topic and, for each event, runs one augmented
// LLM step — the registered services as tools, a fixed prompt — and
// lets the model decide which RPCs to call. Use a Flow when the task is
// well-defined and you want a deterministic trigger; use an Agent (see
// the agent package) when the work needs to direct itself dynamically.
//
// Usage:
//
// f := flow.New("onboard-user",
// flow.Trigger("events.user.created"),
// flow.Prompt("New user created: {{.Data}}. Send welcome email and create workspace."),
// flow.Provider("anthropic"),
// flow.APIKey(key),
// )
// f.Register(service)
// service.Run()
package flow
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"text/template"
"time"
"github.com/google/uuid"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
codecbytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
// Register default providers.
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
// Flow is an event-driven LLM orchestration unit. It subscribes to
// a broker topic, discovers services as tools, and feeds each event
// into an LLM that decides which RPCs to call.
type Flow struct {
name string
opts Options
model ai.Model
toolSet *ai.Tools
client client.Client
tmpl *template.Template
log logger.Logger
checkpoint Checkpoint
reg registry.Registry
sub broker.Subscriber
registration *registry.Service
mu sync.Mutex
results []Result
}
// Result records one flow execution.
type Result struct {
FlowName string `json:"flow"`
Trigger string `json:"trigger"`
Prompt string `json:"prompt"`
Reply string `json:"reply,omitempty"`
Answer string `json:"answer,omitempty"`
ToolCalls []string `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration_seconds"`
}
// New creates a Flow with the given name and options.
func New(name string, opts ...Option) *Flow {
o := Options{
Provider: "openai",
SystemPrompt: "You are a service orchestrator. Use the available tools to fulfill the request. Explain what you do.",
HistoryLimit: 20,
}
for _, opt := range opts {
opt(&o)
}
var tmpl *template.Template
if o.Prompt != "" {
var err error
tmpl, err = template.New(name).Parse(o.Prompt)
if err != nil {
tmpl = template.Must(template.New(name).Parse("{{.Data}}"))
}
}
return &Flow{
name: name,
opts: o,
tmpl: tmpl,
log: logger.DefaultLogger,
checkpoint: defaultCheckpoint(name, o),
}
}
// Register wires the flow into a running service. It sets up the
// model, discovers tools from the registry, and subscribes to the
// trigger topic on the broker. Call this before service.Run().
func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Client) error {
f.client = cl
f.reg = reg
f.toolSet = ai.NewTools(reg, ai.ToolClient(cl))
// A flow that dispatches to an agent doesn't run its own model — the
// agent is the engine. Otherwise, set up the augmented LLM.
if f.opts.Agent == "" {
var modelOpts []ai.Option
if f.opts.APIKey != "" {
modelOpts = append(modelOpts, ai.WithAPIKey(f.opts.APIKey))
}
if f.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(f.opts.Model))
}
if f.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(f.opts.BaseURL))
}
modelOpts = append(modelOpts, ai.WithTools(f.toolSet))
f.model = ai.New(f.opts.Provider, modelOpts...)
if f.model == nil {
return fmt.Errorf("unknown provider: %s", f.opts.Provider)
}
}
if f.opts.TriggerTopic != "" {
sub, err := br.Subscribe(f.opts.TriggerTopic, func(p broker.Event) error {
data := string(p.Message().Body)
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
if err := f.Execute(ctx, data); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
}
return nil
})
if err != nil {
return fmt.Errorf("subscribe to %s: %w", f.opts.TriggerTopic, err)
}
f.sub = sub
f.log.Logf(logger.InfoLevel, "Flow %s subscribed to %s", f.name, f.opts.TriggerTopic)
// Announce the flow in the registry so it's discoverable like a
// service or agent (e.g. `micro flow list`). This is liveness only:
// Stop deregisters it. Durable run history lives in the store.
f.registration = &registry.Service{
Name: f.name,
Version: "latest",
Metadata: map[string]string{
"type": "flow",
"trigger": f.opts.TriggerTopic,
"steps": strconv.Itoa(len(f.opts.Steps)),
},
Nodes: []*registry.Node{{
Id: f.name + "-" + uuid.New().String()[:8],
Address: "flow://" + f.name,
Metadata: map[string]string{"type": "flow"},
}},
}
if err := reg.Register(f.registration); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s registry register: %v", f.name, err)
f.registration = nil
}
}
return nil
}
// Stop unsubscribes the flow from its trigger and deregisters it from the
// 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()
f.sub = nil
}
if f.registration != nil && f.reg != nil {
err := f.reg.Deregister(f.registration)
f.registration = nil
return err
}
return nil
}
// Execute runs the flow once with the given input data. This is
// called automatically on each broker event, but can also be
// invoked directly for testing or one-shot use.
func (f *Flow) Execute(ctx context.Context, data string) error {
ctx, cancel := f.withTimeout(ctx)
defer cancel()
// Stepped flows run the ordered, checkpointed step loop.
if len(f.opts.Steps) > 0 {
_, err := f.startRun(ctx, data)
return err
}
runID := uuid.New().String()
info, _ := ai.RunInfoFrom(ctx)
info.RunID = runID
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
start := time.Now()
prompt := data
if f.tmpl != nil {
var buf bytes.Buffer
_ = f.tmpl.Execute(&buf, map[string]string{"Data": data})
prompt = buf.String()
}
result := Result{
FlowName: f.name,
Trigger: f.opts.TriggerTopic,
Prompt: prompt,
Timestamp: start,
}
// Flow triggers, Agent reasons: hand the event to the named agent.
if f.opts.Agent != "" {
reply, err := f.callAgent(ctx, f.opts.Agent, prompt)
result.Duration = time.Since(start).Seconds()
if err != nil {
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return err
}
result.Reply = reply
f.record(result)
f.log.Logf(logger.InfoLevel, "Flow %s dispatched to agent %s in %.1fs",
f.name, f.opts.Agent, result.Duration)
return nil
}
// Otherwise run a single augmented-LLM step with the services as tools.
discovered, err := f.toolSet.Discover()
if err != nil {
result.Duration = time.Since(start).Seconds()
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return fmt.Errorf("discover tools: %w", err)
}
resp, err := f.model.Generate(ctx, &ai.Request{
Prompt: prompt,
SystemPrompt: f.opts.SystemPrompt,
Tools: discovered,
})
result.Duration = time.Since(start).Seconds()
if err != nil {
result.Error = err.Error()
result.ErrorKind = string(ai.ClassifyError(err))
f.record(result)
return err
}
result.Reply = resp.Reply
result.Answer = resp.Answer
for _, tc := range resp.ToolCalls {
args, _ := json.Marshal(tc.Input)
result.ToolCalls = append(result.ToolCalls, fmt.Sprintf("%s(%s)", tc.Name, args))
}
f.record(result)
f.log.Logf(logger.InfoLevel, "Flow %s completed in %.1fs: %d tool calls",
f.name, result.Duration, len(result.ToolCalls))
return nil
}
// callAgent hands the rendered prompt to a registered agent's Agent.Chat
// endpoint over RPC and returns its reply.
func (f *Flow) callAgent(ctx context.Context, name, message string) (string, error) {
info, _ := ai.RunInfoFrom(ctx)
body, _ := json.Marshal(map[string]string{"message": message, "parent_id": info.RunID})
req := f.client.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
var rsp codecbytes.Frame
if err := f.client.Call(ctx, req, &rsp); err != nil {
return "", err
}
var out struct {
Reply string `json:"reply"`
}
if err := json.Unmarshal(rsp.Data, &out); err != nil {
return "", err
}
return out.Reply, nil
}
// Results returns a copy of all recorded execution results.
func (f *Flow) Results() []Result {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]Result, len(f.results))
copy(out, f.results)
return out
}
// Name returns the flow name.
func (f *Flow) Name() string {
return f.name
}
func (f *Flow) record(r Result) {
f.mu.Lock()
f.results = append(f.results, r)
f.mu.Unlock()
if f.opts.OnResult != nil {
f.opts.OnResult(r)
}
}
+131
View File
@@ -0,0 +1,131 @@
package flow
import (
"context"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
)
func TestNew(t *testing.T) {
f := New("test-flow",
Trigger("events.test"),
Prompt("Handle this: {{.Data}}"),
Provider("anthropic"),
APIKey("test-key"),
HistoryLimit(10),
)
if f.Name() != "test-flow" {
t.Errorf("name = %q, want test-flow", f.Name())
}
if f.opts.TriggerTopic != "events.test" {
t.Errorf("trigger = %q", f.opts.TriggerTopic)
}
if f.opts.Provider != "anthropic" {
t.Errorf("provider = %q", f.opts.Provider)
}
if f.opts.HistoryLimit != 10 {
t.Errorf("history limit = %d", f.opts.HistoryLimit)
}
if f.tmpl == nil {
t.Fatal("template not parsed")
}
}
func TestPromptTemplate(t *testing.T) {
f := New("tmpl-test",
Prompt("User created: {{.Data}}. Send welcome email."),
)
// Test that the template renders
if f.tmpl == nil {
t.Fatal("template not parsed")
}
}
func TestResultsEmpty(t *testing.T) {
f := New("empty")
results := f.Results()
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestOnResultCallback(t *testing.T) {
var called bool
f := New("callback",
OnResult(func(r Result) {
called = true
if r.FlowName != "callback" {
t.Errorf("flow name = %q", r.FlowName)
}
}),
)
f.record(Result{FlowName: "callback"})
if !called {
t.Error("OnResult not called")
}
if len(f.Results()) != 1 {
t.Errorf("results = %d, want 1", len(f.Results()))
}
}
func TestDefaultOptions(t *testing.T) {
f := New("defaults")
if f.opts.Provider != "openai" {
t.Errorf("default provider = %q, want openai", f.opts.Provider)
}
if f.opts.HistoryLimit != 20 {
t.Errorf("default history limit = %d, want 20", f.opts.HistoryLimit)
}
if f.opts.SystemPrompt == "" {
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" }
+151
View File
@@ -0,0 +1,151 @@
package flow
import (
"context"
"fmt"
"strings"
"go-micro.dev/v6/ai"
)
// LoopCondition decides whether a Loop should stop, given the latest state
// and the iteration just completed (1-based). Returning true ends the loop.
type LoopCondition func(ctx context.Context, state State, iter int) (bool, error)
// LoopOptions configure a Loop. Max is the hard iteration cap — the ceiling
// that guarantees the loop always terminates, however the stop is decided.
// Until and UntilLLM are the optional early-stop checks.
type LoopOptions struct {
Max int
Until LoopCondition
UntilLLM string
OnIter func(iter int, state State)
}
// LoopOption configures a Loop.
type LoopOption func(*LoopOptions)
// LoopMax sets the hard iteration cap — the budget guardrail. The loop never
// runs the body more than n times, so it always terminates even when the
// stop condition never fires. Default 10.
func LoopMax(n int) LoopOption { return func(o *LoopOptions) { o.Max = n } }
// Until stops the loop when cond returns true after an iteration — a
// deterministic, code-defined exit condition.
func Until(cond LoopCondition) LoopOption { return func(o *LoopOptions) { o.Until = cond } }
// UntilLLM stops the loop when the flow's model judges the goal met. After
// each iteration it asks the model the question with the latest state and
// stops on an affirmative answer — the agent decides when it's done (the
// supervised "Ralph" loop), while LoopMax guarantees termination. Requires a
// flow model (set Provider/APIKey).
func UntilLLM(question string) LoopOption { return func(o *LoopOptions) { o.UntilLLM = question } }
// OnIteration runs fn after each iteration — useful for logging progress or
// persisting intermediate state.
func OnIteration(fn func(iter int, state State)) LoopOption {
return func(o *LoopOptions) { o.OnIter = fn }
}
// Loop returns a StepFunc that runs body repeatedly until a stop condition is
// met or the iteration cap is reached, whichever comes first — the agentic
// "loop": keep working until the goal is done, with a guaranteed ceiling so
// it can never run away.
//
// Compose it as a flow step. The carried State flows from one pass to the
// next, so each iteration sees the previous result:
//
// flow.New("refactor",
// flow.Provider("anthropic"),
// flow.Steps(
// flow.Step{Name: "improve", Run: flow.Loop(
// flow.Dispatch("coder"),
// flow.UntilLLM("Is the refactor complete with no duplicated abstractions left?"),
// flow.LoopMax(5),
// )},
// ),
// )
//
// The loop runs as a single flow step: the flow checkpoints the loop's
// outcome, and a resume re-enters the step, so loop bodies should be safe to
// repeat. Use OnIteration to record per-pass progress. If the cap is hit
// before the stop condition fires, the loop returns the latest state rather
// than erroring — the guardrail did its job.
func Loop(body StepFunc, opts ...LoopOption) StepFunc {
o := LoopOptions{Max: 10}
for _, op := range opts {
op(&o)
}
if o.Max <= 0 {
o.Max = 10
}
return func(ctx context.Context, in State) (State, error) {
if body == nil {
return in, fmt.Errorf("flow: Loop requires a body step")
}
cur := in
for iter := 1; iter <= o.Max; iter++ {
out, err := body(ctx, cur)
if err != nil {
return cur, fmt.Errorf("loop iteration %d: %w", iter, err)
}
cur = out
if o.OnIter != nil {
o.OnIter(iter, cur)
}
done, err := loopDone(ctx, o, cur, iter)
if err != nil {
return cur, err
}
if done {
return cur, nil
}
}
return cur, nil
}
}
// loopDone evaluates the stop conditions: a code-defined Until predicate
// and/or an LLM judgement. Either firing stops the loop.
func loopDone(ctx context.Context, o LoopOptions, state State, iter int) (bool, error) {
if o.Until != nil {
done, err := o.Until(ctx, state, iter)
if err != nil || done {
return done, err
}
}
if o.UntilLLM != "" {
return askDone(ctx, o.UntilLLM, state)
}
return false, nil
}
// askDone asks the flow model whether the goal is met given the current
// state, and returns true on an affirmative reply — the supervised stop check.
func askDone(ctx context.Context, question string, state State) (bool, error) {
d := depsFrom(ctx)
if d == nil || d.model == nil {
return false, fmt.Errorf("flow: UntilLLM requires a flow model (set Provider/APIKey)")
}
prompt := fmt.Sprintf("%s\n\nLatest result:\n%s\n\nAnswer with only \"yes\" or \"no\".", question, state.String())
resp, err := d.model.Generate(ctx, &ai.Request{Prompt: prompt})
if err != nil {
return false, err
}
reply := resp.Answer
if reply == "" {
reply = resp.Reply
}
return isAffirmative(reply), nil
}
// isAffirmative reports whether a model reply reads as "yes/done".
func isAffirmative(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
for _, p := range []string{"yes", "done", "true", "complete", "finished"} {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
+91
View File
@@ -0,0 +1,91 @@
package flow
import (
"context"
"strconv"
"testing"
)
// counter body: increments an integer carried in State.Data.
func counter() StepFunc {
return func(ctx context.Context, in State) (State, error) {
n, _ := strconv.Atoi(in.String())
in.Data = []byte(strconv.Itoa(n + 1))
return in, nil
}
}
func TestLoopUntil(t *testing.T) {
step := Loop(counter(),
Until(func(ctx context.Context, s State, iter int) (bool, error) {
n, _ := strconv.Atoi(s.String())
return n >= 3, nil
}),
LoopMax(100),
)
out, err := step(context.Background(), State{Data: []byte("0")})
if err != nil {
t.Fatal(err)
}
if out.String() != "3" {
t.Fatalf("expected 3, got %q", out.String())
}
}
func TestLoopMaxCapStops(t *testing.T) {
runs := 0
body := func(ctx context.Context, in State) (State, error) { runs++; return in, nil }
// condition never fires; the cap must stop it
step := Loop(body,
Until(func(ctx context.Context, s State, iter int) (bool, error) { return false, nil }),
LoopMax(5),
)
if _, err := step(context.Background(), State{}); err != nil {
t.Fatal(err)
}
if runs != 5 {
t.Fatalf("expected 5 iterations (cap), got %d", runs)
}
}
func TestLoopOnIteration(t *testing.T) {
var seen []int
body := func(ctx context.Context, in State) (State, error) { return in, nil }
step := Loop(body, LoopMax(3), OnIteration(func(iter int, s State) { seen = append(seen, iter) }))
if _, err := step(context.Background(), State{}); err != nil {
t.Fatal(err)
}
if len(seen) != 3 || seen[0] != 1 || seen[2] != 3 {
t.Fatalf("expected iterations [1 2 3], got %v", seen)
}
}
func TestLoopBodyError(t *testing.T) {
body := func(ctx context.Context, in State) (State, error) {
return in, context.Canceled
}
step := Loop(body, LoopMax(3))
if _, err := step(context.Background(), State{}); err == nil {
t.Fatal("expected error from body to propagate")
}
}
func TestIsAffirmative(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"yes", true},
{"Yes, the goal is met.", true},
{"DONE", true},
{"complete", true},
{"no", false},
{"not yet", false},
{"", false},
}
for _, c := range cases {
if got := isAffirmative(c.in); got != c.want {
t.Errorf("isAffirmative(%q) = %v, want %v", c.in, got, c.want)
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package flow
import (
"time"
"go.opentelemetry.io/otel/trace"
)
// Options configures a Flow.
type Options struct {
// TriggerTopic is the broker topic that triggers this flow.
TriggerTopic string
// Prompt is a Go template string. {{.Data}} is the event payload.
Prompt string
// SystemPrompt is the system instruction for the LLM.
SystemPrompt string
// Provider is the AI provider name (e.g. "anthropic", "openai").
Provider string
// APIKey for the AI provider.
APIKey string
// Model overrides the provider's default model.
Model string
// BaseURL overrides the provider's default base URL.
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
// to (over RPC). The flow triggers; the agent reasons. When empty,
// the flow runs a single augmented-LLM step itself.
Agent string
// Steps, if set, makes the flow run an ordered list of steps per
// event instead of a single LLM step — the deterministic-workflow
// path. Checkpointed between steps when a Checkpoint is set.
Steps []Step
// Retry is the flow-level retry count applied to each step (0 = no
// retry). A Step's own Retry field overrides this.
Retry int
// RetryBackoff is the delay between failed step attempts. Zero means
// retry immediately; cancellation/deadline stops the wait early.
RetryBackoff time.Duration
// Checkpoint is the durability backend for stepped runs. Nil with
// steps present means a store-backed default; set it to swap backends.
Checkpoint Checkpoint
// TraceProvider emits OpenTelemetry spans for stepped flow runs.
TraceProvider trace.TracerProvider
// DeleteOnSuccess removes a run's checkpoint when it completes
// successfully. Failed runs are always retained. Default: retain all.
DeleteOnSuccess bool
}
// Option applies a configuration to Options.
type Option func(*Options)
// Trigger sets the broker topic that triggers this flow.
func Trigger(topic string) Option {
return func(o *Options) { o.TriggerTopic = topic }
}
// Prompt sets the prompt template. Use {{.Data}} for the event payload.
func Prompt(p string) Option {
return func(o *Options) { o.Prompt = p }
}
// SystemPrompt sets the system instruction for the LLM.
func SystemPrompt(p string) Option {
return func(o *Options) { o.SystemPrompt = p }
}
// Provider sets the AI provider name.
func Provider(name string) Option {
return func(o *Options) { o.Provider = name }
}
// APIKey sets the API key for the AI provider.
func APIKey(key string) Option {
return func(o *Options) { o.APIKey = key }
}
// Model sets the model name.
func Model(name string) Option {
return func(o *Options) { o.Model = name }
}
// BaseURL sets the provider base URL.
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// HistoryLimit sets the max messages per execution.
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 }
}
// Agent makes the flow hand each event to a named registered agent over
// RPC instead of running its own LLM step. The flow triggers; the agent
// reasons (with its plan, delegate, memory, and guardrails).
func Agent(name string) Option {
return func(o *Options) { o.Agent = name }
}
// Steps sets the ordered steps of the flow. A flow with steps runs them
// in order per event, checkpointing between each, instead of the
// single-step prompt/agent behavior. Step names must be unique.
func Steps(steps ...Step) Option {
return func(o *Options) { o.Steps = steps }
}
// Retry sets the flow-level retry count applied to each step (0 = no
// retry). A Step's own Retry field overrides this.
func Retry(n int) Option {
return func(o *Options) { o.Retry = n }
}
// RetryBackoff sets the delay between failed step attempts. A zero
// duration preserves immediate retries. If the run context is canceled
// while waiting, the context error is returned instead of retrying.
func RetryBackoff(d time.Duration) Option {
return func(o *Options) { o.RetryBackoff = d }
}
// WithCheckpoint sets the durability backend. With a checkpoint, a run is
// persisted before and after each step and can be resumed after a crash.
// Stepped flows default to a store-backed checkpoint; use this to swap it.
func WithCheckpoint(c Checkpoint) Option {
return func(o *Options) { o.Checkpoint = c }
}
// DeleteOnSuccess removes a run's checkpoint when it completes
// successfully. Failed runs are always retained. Default: retain all.
func DeleteOnSuccess() Option {
return func(o *Options) { o.DeleteOnSuccess = true }
}
// TraceProvider enables OpenTelemetry spans for stepped flow runs and steps.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) { o.TraceProvider = tp }
}
+114
View File
@@ -0,0 +1,114 @@
package flow
import (
"context"
"time"
"go-micro.dev/v6/ai"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
const flowInstrumentationName = "go-micro.dev/v6/flow"
const (
spanNameFlowRun = "flow.run"
spanNameFlowStep = "flow.step"
AttrFlowRunID = "flow.run.id"
AttrFlowParentID = "flow.run.parent_id"
AttrFlowName = "flow.name"
AttrFlowStepName = "flow.step.name"
AttrFlowStatus = "flow.status"
AttrFlowAttempts = "flow.step.attempts"
AttrFlowLatencyMS = "flow.latency_ms"
AttrFlowErrorKind = "flow.error.kind"
AttrFlowVerificationStatus = "flow.verification.status"
AttrFlowVerificationNote = "flow.verification.note"
AttrFlowDispatch = "flow.dispatch"
AttrFlowTrigger = "flow.trigger"
)
func (f *Flow) tracer() trace.Tracer {
return f.opts.TraceProvider.Tracer(flowInstrumentationName)
}
func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func(Run, error)) {
if f.opts.TraceProvider == nil {
return ctx, func(Run, error) {}
}
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(
attribute.String(AttrFlowStatus, done.Status),
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
)
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
}
}
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, Verification, error) {
if f.opts.TraceProvider == nil {
return f.runStep(ctx, step, in)
}
info, _ := ai.RunInfoFrom(ctx)
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(
attribute.Int(AttrFlowAttempts, attempts),
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
)
if verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "passed"))
}
if verification.Feedback != "" {
span.SetAttributes(attribute.String(AttrFlowVerificationNote, verification.Feedback))
if !verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
}
}
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
return out, attempts, verification, err
}
func appendRunInfoDispatch(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrFlowDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrFlowTrigger, info.Trigger))
}
return attrs
}
+102
View File
@@ -0,0 +1,102 @@
package flow
import (
"context"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestFlowOpenTelemetrySpans(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("done")
return in, nil
}}
f := New("observed", WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "observed")), TraceProvider(tp), Steps(step))
ctx := withTestRunInfo(context.Background(), "agent-run-otel")
if err := f.Execute(ctx, "start"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
seen := map[string]bool{spanNameFlowRun: false, spanNameFlowStep: false}
var runID string
for _, span := range spans {
attrs := flowSpanAttributes(span.Attributes())
switch span.Name() {
case spanNameFlowRun:
seen[spanNameFlowRun] = true
runID = attrs[AttrFlowRunID]
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStatus] != "done" || attrs[AttrFlowParentID] != "agent-run-otel" {
t.Fatalf("run span attributes = %#v", attrs)
}
case spanNameFlowStep:
seen[spanNameFlowStep] = true
if attrs[AttrFlowName] != "observed" || attrs[AttrFlowStepName] != "inspect" || attrs[AttrFlowParentID] != "agent-run-otel" {
t.Fatalf("step span attributes = %#v", attrs)
}
}
}
for name, ok := range seen {
if !ok {
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
}
}
if runID == "" {
t.Fatal("run span missing run id")
}
for _, span := range spans {
if span.Name() != spanNameFlowStep {
continue
}
attrs := flowSpanAttributes(span.Attributes())
if attrs[AttrFlowRunID] != runID {
t.Fatalf("step span run id = %q, want %q", attrs[AttrFlowRunID], runID)
}
}
}
func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
out := make(map[string]string, len(attrs))
for _, attr := range attrs {
out[string(attr.Key)] = attr.Value.AsString()
}
return out
}
func withTestRunInfo(ctx context.Context, runID string) context.Context {
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
}
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")
}
+65
View File
@@ -0,0 +1,65 @@
package flow
import (
"context"
"testing"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
)
// A trigger-bound flow announces itself in the registry as type=flow
// while running, and deregisters on Stop — liveness, like a service.
func TestFlowRegistersAndDeregisters(t *testing.T) {
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
if err := br.Connect(); err != nil {
t.Fatalf("broker connect: %v", err)
}
f := New("onboard",
Trigger("events.user.created"),
Steps(appendStep("a")),
)
if err := f.Register(reg, br, client.DefaultClient); err != nil {
t.Fatalf("Register: %v", err)
}
svcs, err := reg.GetService("onboard")
if err != nil || len(svcs) == 0 {
t.Fatalf("flow not registered: %v", err)
}
if svcs[0].Metadata["type"] != "flow" {
t.Errorf("registry metadata type = %q, want flow", svcs[0].Metadata["type"])
}
if svcs[0].Metadata["trigger"] != "events.user.created" {
t.Errorf("registry metadata trigger = %q", svcs[0].Metadata["trigger"])
}
if err := f.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if svcs, _ := reg.GetService("onboard"); len(svcs) != 0 {
t.Errorf("flow should be deregistered after Stop, got %d services", len(svcs))
}
}
// A flow without a trigger is not a running listener and isn't registered.
func TestFlowWithoutTriggerNotRegistered(t *testing.T) {
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
_ = br.Connect()
f := New("oneshot", Steps(appendStep("a")))
if err := f.Register(reg, br, client.DefaultClient); err != nil {
t.Fatalf("Register: %v", err)
}
if svcs, _ := reg.GetService("oneshot"); len(svcs) != 0 {
t.Errorf("triggerless flow should not register, got %d", len(svcs))
}
// It still runs on demand.
if err := f.Execute(context.Background(), ""); err != nil {
t.Fatalf("Execute: %v", err)
}
}
+60
View File
@@ -0,0 +1,60 @@
package flow
import (
"context"
"time"
"go-micro.dev/v6/ai"
)
// Schedule binds a flow to a recurring work item without introducing a
// scheduler service. It is a small harness contract: callers own the clock,
// Go Micro owns turning each tick into the same inspectable flow run used for
// broker events and direct Execute calls.
type Schedule struct {
flow *Flow
data string
}
// Scheduled returns a deterministic scheduled-run harness for this flow.
// Tests and event loops can call Tick directly; production processes can wire
// the same contract to time.Ticker through RunEvery. Each tick calls Execute, so
// checkpointed run history, parent/run metadata, cancellation, and inspection
// stay on the normal flow surfaces.
func Scheduled(f *Flow, data string) Schedule {
return Schedule{flow: f, data: data}
}
// Tick starts one scheduled run immediately and returns when that run finishes.
func (s Schedule) Tick(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
info, _ := ai.RunInfoFrom(ctx)
info.Dispatch = "schedule"
if info.Trigger == "" {
info.Trigger = s.flow.opts.TriggerTopic
}
if info.Trigger == "" {
info.Trigger = "schedule"
}
return s.flow.Execute(ai.WithRunInfo(ctx, info), s.data)
}
// RunEvery drives scheduled runs from a ticker until ctx is canceled. It does
// not persist schedule definitions or host a scheduler; it only adapts a caller
// owned cadence to Tick.
func (s Schedule) RunEvery(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := s.Tick(ctx); err != nil {
return err
}
}
}
}
+635
View File
@@ -0,0 +1,635 @@
package flow
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"text/template"
"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/gateway/a2a"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/store"
)
// State carries data across the steps of a flow run. It is a struct, not
// a map: Data is the serialized payload (set and read with Set/Scan), and
// Stage names the step the run is at — so you can always tell where it is,
// and the engine uses it as the resume point.
type State struct {
Stage string `json:"stage"`
Data []byte `json:"data"`
}
// Set replaces the data with the JSON encoding of v.
func (s *State) Set(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
s.Data = b
return nil
}
// Scan decodes the data into v (a pointer to the caller's struct).
func (s State) Scan(v any) error {
if len(s.Data) == 0 {
return nil
}
return json.Unmarshal(s.Data, v)
}
// String returns the data as a string, for text payloads.
func (s State) String() string { return string(s.Data) }
// StepFunc performs one step's work: it receives the carried state and
// returns the next state.
type StepFunc func(ctx context.Context, in State) (State, error)
// Verifier grades a step output before the flow advances. Returning
// Passed=false converts the grade into a retryable VerificationError, so
// the existing step retry/supervision path can feed Feedback into the next
// attempt through ai.RunInfo.VerificationFeedback.
type Verifier func(ctx context.Context, out State) (Verification, error)
// Verification is the verifier's deterministic grade for one step attempt.
type Verification struct {
Passed bool
Feedback string
}
// VerificationError reports a failed grade. It is returned from runStep so
// existing retry, checkpoint, and trace paths handle verifier failures the
// same way they handle step execution failures.
type VerificationError struct {
Step string
Feedback string
}
func (e *VerificationError) Error() string {
if e.Feedback == "" {
return fmt.Sprintf("flow: verification failed for step %q", e.Step)
}
return fmt.Sprintf("flow: verification failed for step %q: %s", e.Step, e.Feedback)
}
// Step is one unit of a flow — a named action with optional retry and
// verification hooks. There is one Step kind; the action is the Run func,
// and the Call/LLM/Agent helpers produce the common ones.
type Step struct {
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
Verify Verifier // optional grade; failed grades retry the step with feedback in RunInfo
}
// StepRecord is the recorded outcome of one step within a run.
type StepRecord struct {
Name string `json:"name"`
Status string `json:"status"` // pending | in_progress | done | failed
Attempts int `json:"attempts"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
VerificationStatus string `json:"verification_status,omitempty"` // passed | failed
VerificationNote string `json:"verification_note,omitempty"`
}
// Run is the persisted record of one flow execution — what a Checkpoint
// saves and loads. It is retained for success and failure unless the flow
// opts into cleanup with DeleteOnSuccess.
type Run struct {
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
Flow string `json:"flow"`
State State `json:"state"`
Steps []StepRecord `json:"steps"`
Status string `json:"status"` // running | done | failed
Started time.Time `json:"started"`
Updated time.Time `json:"updated"`
}
// Checkpoint persists and restores flow runs so a run survives a crash
// and resumes where it stopped. The built-in StoreCheckpoint is
// store-backed; implement this interface to plug in another durable
// execution backend.
type Checkpoint interface {
Save(ctx context.Context, run Run) error
Load(ctx context.Context, runID string) (Run, bool, error)
Delete(ctx context.Context, runID string) error
List(ctx context.Context) ([]Run, error)
}
type storeCheckpoint struct {
store store.Store
}
// StoreCheckpoint returns a store-backed Checkpoint that keeps a flow's
// runs in their own store table — pass the flow name as scope, so one
// flow's runs never share a table with another's (or with service or
// agent state). A nil store uses store.DefaultStore.
func StoreCheckpoint(s store.Store, scope string) Checkpoint {
if s == nil {
s = store.DefaultStore
}
// Confine runs to the "flow" database, one table per flow name. The
// scoped handle injects this per-operation without mutating s.
return &storeCheckpoint{store: store.Scope(s, "flow", scope)}
}
func (c *storeCheckpoint) Save(ctx context.Context, run Run) error {
if err := ctx.Err(); err != nil {
return err
}
run.Updated = time.Now()
b, err := json.Marshal(run)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return c.store.Write(&store.Record{Key: run.ID, Value: b})
}
func (c *storeCheckpoint) Load(ctx context.Context, runID string) (Run, bool, error) {
if err := ctx.Err(); err != nil {
return Run{}, false, err
}
recs, err := c.store.Read(runID)
if err == store.ErrNotFound || len(recs) == 0 {
return Run{}, false, nil
}
if err != nil {
return Run{}, false, err
}
var run Run
if err := json.Unmarshal(recs[0].Value, &run); err != nil {
return Run{}, false, err
}
return run, true, nil
}
func (c *storeCheckpoint) Delete(ctx context.Context, runID string) error {
if err := ctx.Err(); err != nil {
return err
}
return c.store.Delete(runID)
}
func (c *storeCheckpoint) List(ctx context.Context) ([]Run, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
keys, err := c.store.List()
if err != nil {
return nil, err
}
var runs []Run
for _, id := range keys {
if run, ok, err := c.Load(ctx, id); err == nil && ok {
runs = append(runs, run)
}
}
sort.SliceStable(runs, func(i, j int) bool {
if runs[i].Started.Equal(runs[j].Started) {
return runs[i].ID < runs[j].ID
}
return runs[i].Started.Before(runs[j].Started)
})
return runs, nil
}
// defaultCheckpoint returns the configured checkpoint, or a store-backed
// default scoped to the flow name when the flow has steps (durable by
// default). Scoping by name keeps each flow's runs in their own keyspace
// rather than a global one.
func defaultCheckpoint(name string, o Options) Checkpoint {
if o.Checkpoint != nil {
return o.Checkpoint
}
if len(o.Steps) > 0 {
return StoreCheckpoint(store.DefaultStore, name)
}
return nil
}
// runDeps are the flow resources the Call/LLM/Agent step helpers need.
// They are injected into the context for the duration of a run so a
// StepFunc keeps the clean (ctx, State) signature.
type runDeps struct {
client client.Client
model ai.Model
tools *ai.Tools
}
type runCtxKey struct{}
func withDeps(ctx context.Context, d *runDeps) context.Context {
return context.WithValue(ctx, runCtxKey{}, d)
}
func depsFrom(ctx context.Context) *runDeps {
d, _ := ctx.Value(runCtxKey{}).(*runDeps)
return d
}
// Call returns a StepFunc that invokes an RPC endpoint, sending the
// current state Data as the request body and storing the response as the
// new Data.
func Call(service, endpoint string) StepFunc {
return func(ctx context.Context, in State) (State, error) {
cl := client.DefaultClient
if d := depsFrom(ctx); d != nil && d.client != nil {
cl = d.client
}
body := in.Data
if len(body) == 0 {
body = []byte("{}")
}
req := cl.NewRequest(service, endpoint, &codecbytes.Frame{Data: body})
var rsp codecbytes.Frame
if err := cl.Call(ctx, req, &rsp); err != nil {
return in, err
}
in.Data = rsp.Data
return in, nil
}
}
// Dispatch returns a StepFunc that hands the current state Data (as the
// message) to a registered agent's Agent.Chat endpoint and stores the
// reply as the new Data.
func Dispatch(name string) StepFunc {
return func(ctx context.Context, in State) (State, error) {
cl := client.DefaultClient
if d := depsFrom(ctx); d != nil && d.client != nil {
cl = d.client
}
info, _ := ai.RunInfoFrom(ctx)
body, _ := json.Marshal(map[string]string{"message": in.String(), "parent_id": info.RunID})
req := cl.NewRequest(name, "Agent.Chat", &codecbytes.Frame{Data: body})
var rsp codecbytes.Frame
if err := cl.Call(ctx, req, &rsp); err != nil {
return in, err
}
var out struct {
Reply string `json:"reply"`
}
_ = json.Unmarshal(rsp.Data, &out)
in.Data = []byte(out.Reply)
return in, nil
}
}
// A2A returns a StepFunc that calls a remote agent over the A2A protocol
// by URL — the cross-framework counterpart to Dispatch. It sends the
// current state Data as the message and stores the reply as the new Data.
func A2A(url string) StepFunc {
return func(ctx context.Context, in State) (State, error) {
reply, err := a2a.NewClient(url).Send(ctx, in.String())
if err != nil {
return in, err
}
in.Data = []byte(reply)
return in, nil
}
}
// LLM returns a StepFunc that runs one augmented-LLM turn: it renders the
// prompt template against the current state (.Data, .Stage), lets the
// model call the flow's services as tools, and stores the reply as the
// new Data.
func LLM(prompt string) StepFunc {
return func(ctx context.Context, in State) (State, error) {
d := depsFrom(ctx)
if d == nil || d.model == nil {
return in, fmt.Errorf("LLM step requires a flow model (set Provider/APIKey)")
}
text := prompt
if tmpl, err := template.New("step").Parse(prompt); err == nil {
var buf bytes.Buffer
if tmpl.Execute(&buf, map[string]string{"Data": in.String(), "Stage": in.Stage}) == nil {
text = buf.String()
}
}
var tools []ai.Tool
if d.tools != nil {
tools, _ = d.tools.Discover()
}
resp, err := d.model.Generate(ctx, &ai.Request{Prompt: text, Tools: tools})
if err != nil {
return in, err
}
reply := resp.Answer
if reply == "" {
reply = resp.Reply
}
in.Data = []byte(reply)
return in, nil
}
}
// startRun begins a fresh run of the flow's steps with the given input.
func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
if err := validateSteps(f.opts.Steps); err != nil {
return Run{}, err
}
parentID := ""
if info, ok := ai.RunInfoFrom(ctx); ok {
parentID = info.RunID
}
run := Run{
ID: uuid.New().String(),
ParentID: parentID,
Flow: f.name,
State: State{Stage: f.opts.Steps[0].Name, Data: []byte(data)},
Status: "running",
Started: time.Now(),
}
for _, s := range f.opts.Steps {
run.Steps = append(run.Steps, StepRecord{Name: s.Name, Status: "pending"})
}
return f.runFrom(ctx, run)
}
// Resume continues a persisted run by id, picking up at the step it
// stopped on. Completed runs are a no-op.
func (f *Flow) Resume(ctx context.Context, runID string) error {
ctx, cancel := f.withTimeout(ctx)
defer cancel()
if err := validateSteps(f.opts.Steps); err != nil {
return err
}
if f.checkpoint == nil {
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
}
run, ok, err := f.checkpoint.Load(ctx, runID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("run %s not found", runID)
}
if run.Status == "done" {
return nil
}
_, err = f.runFrom(ctx, run)
return err
}
// ResumePending resumes every checkpointed run for this flow that has not
// completed yet, in the same oldest-first order returned by Pending.
//
// It is a convenience for service startup and recovery loops: after a process
// restart, call ResumePending to drain the durable backlog without having to
// list and resume each run manually. If any run fails again, ResumePending
// 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
}
for _, run := range runs {
if err := f.Resume(ctx, run.ID); err != nil {
return run.ID, err
}
}
return "", nil
}
// Pending returns this flow's runs that have not completed — the ones a
// process restart should resume.
func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
if f.checkpoint == nil {
return nil, nil
}
all, err := f.checkpoint.List(ctx)
if err != nil {
return nil, err
}
var out []Run
for _, r := range all {
if r.Flow == f.name && r.Status != "done" {
out = append(out, r)
}
}
return out, nil
}
// runFrom executes steps from the run's current Stage to the end,
// checkpointing before and after each step.
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
steps := f.opts.Steps
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
info, _ := ai.RunInfoFrom(ctx)
info.RunID = run.ID
info.ParentID = run.ParentID
info.Agent = f.name
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
ctx, finishSpan := f.startRunSpan(ctx, run)
var spanErr error
defer func() { finishSpan(run, spanErr) }()
start := stepIndex(steps, run.State.Stage)
if start < 0 {
if run.State.Stage == "" {
start = len(steps) // already finished
} else {
start = 0
}
}
for i := start; i < len(steps); i++ {
step := steps[i]
run.State.Stage = step.Name
run.Steps[i].Status = "in_progress"
if err := f.save(ctx, run); err != nil {
spanErr = err
return run, err
}
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
run.Steps[i].Attempts = attempts
applyVerificationRecord(&run.Steps[i], verification)
if err != nil {
spanErr = err
run.Steps[i].Status = "failed"
run.Steps[i].Error = err.Error()
run.Steps[i].ErrorKind = string(ai.ClassifyError(err))
run.Status = "failed"
if saveErr := f.save(ctx, run); saveErr != nil {
spanErr = saveErr
return run, fmt.Errorf("%w; additionally failed to checkpoint failed run: %v", err, saveErr)
}
f.record(resultFromRun(f.opts.TriggerTopic, run))
f.log.Logf(logger.ErrorLevel, "Flow %s run %s failed at step %q: %v", f.name, run.ID, step.Name, err)
return run, err
}
run.State = out
run.Steps[i].Status = "done"
run.Steps[i].Result = truncate(out.String(), 200)
if i+1 < len(steps) {
run.State.Stage = steps[i+1].Name
} else {
run.State.Stage = ""
}
if err := f.save(ctx, run); err != nil {
spanErr = err
return run, err
}
}
run.Status = "done"
if err := f.save(ctx, run); err != nil {
spanErr = err
return run, err
}
if f.opts.DeleteOnSuccess && f.checkpoint != nil {
if err := f.checkpoint.Delete(ctx, run.ID); err != nil {
spanErr = err
return run, err
}
}
f.record(resultFromRun(f.opts.TriggerTopic, run))
f.log.Logf(logger.InfoLevel, "Flow %s run %s completed (%d steps)", f.name, run.ID, len(steps))
return run, nil
}
// runStep runs one step, retrying on error up to the resolved retry count.
// A step with no Run function is a configuration error, and a canceled run
// stops retrying immediately rather than burning the rest of its budget.
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Verification, error) {
if step.Run == nil {
return in, 0, Verification{}, fmt.Errorf("flow: step %q has no Run function", step.Name)
}
retries := f.opts.Retry
if step.Retry > 0 {
retries = step.Retry
}
var lastErr error
var lastVerification Verification
var feedback string
for attempt := 1; attempt <= retries+1; attempt++ {
// Stop the moment the run's context is canceled or its deadline
// passes — a canceled run shouldn't keep retrying, and the context
// error is surfaced so callers can detect cancellation upstream.
if err := ctx.Err(); err != nil {
return in, attempt - 1, lastVerification, err
}
attemptCtx := ctx
if info, ok := ai.RunInfoFrom(ctx); ok {
info.Step = step.Name
info.VerificationFeedback = feedback
attemptCtx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(attemptCtx, in)
if err == nil && step.Verify != nil {
lastVerification, err = step.Verify(attemptCtx, out)
if err == nil && !lastVerification.Passed {
err = &VerificationError{Step: step.Name, Feedback: lastVerification.Feedback}
}
}
if err == nil {
return out, attempt, lastVerification, nil
}
lastErr = err
if verr, ok := err.(*VerificationError); ok {
feedback = verr.Feedback
}
if attempt <= retries && f.opts.RetryBackoff > 0 {
select {
case <-time.After(f.opts.RetryBackoff):
case <-ctx.Done():
return in, attempt, lastVerification, ctx.Err()
}
}
}
return in, retries + 1, lastVerification, lastErr
}
func applyVerificationRecord(record *StepRecord, verification Verification) {
if verification.Passed {
record.VerificationStatus = "passed"
}
if verification.Feedback != "" {
record.VerificationNote = truncate(verification.Feedback, 200)
if !verification.Passed {
record.VerificationStatus = "failed"
}
}
}
func (f *Flow) save(ctx context.Context, run Run) error {
if f.checkpoint == nil {
return nil
}
if err := f.checkpoint.Save(ctx, run); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s checkpoint save: %v", f.name, err)
return fmt.Errorf("flow %s checkpoint save: %w", f.name, err)
}
return nil
}
func validateSteps(steps []Step) error {
seen := make(map[string]struct{}, len(steps))
for i, step := range steps {
if step.Name == "" {
return fmt.Errorf("flow: step %d has an empty name", i)
}
if _, ok := seen[step.Name]; ok {
return fmt.Errorf("flow: duplicate step name %q", step.Name)
}
seen[step.Name] = struct{}{}
}
return nil
}
func stepIndex(steps []Step, name string) int {
for i, s := range steps {
if s.Name == name {
return i
}
}
return -1
}
func resultFromRun(trigger string, run Run) Result {
r := Result{
FlowName: run.Flow,
Trigger: trigger,
Timestamp: run.Started,
Duration: run.Updated.Sub(run.Started).Seconds(),
}
for _, s := range run.Steps {
r.ToolCalls = append(r.ToolCalls, s.Name+":"+s.Status)
if s.Error != "" {
r.Error = s.Error
r.ErrorKind = s.ErrorKind
}
}
if run.Status == "done" {
r.Answer = run.State.String()
}
return r
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+584
View File
@@ -0,0 +1,584 @@
package flow
import (
"context"
"errors"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
// appendStep returns a step that appends its name to the carried data,
// so a run's path is visible in the final State.
func appendStep(name string) Step {
return Step{Name: name, Run: func(_ context.Context, in State) (State, error) {
s := in.String()
if s != "" {
s += ","
}
in.Data = []byte(s + name)
return in, nil
}}
}
func TestFlowStepsRunInOrder(t *testing.T) {
f := New("seq",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "seq")),
Steps(appendStep("a"), appendStep("b"), appendStep("c")),
)
if err := f.Execute(context.Background(), ""); err != nil {
t.Fatalf("Execute: %v", err)
}
res := f.Results()
if len(res) != 1 || res[0].Answer != "a,b,c" {
t.Fatalf("steps ran out of order: %+v", res)
}
}
// A run that fails mid-way is persisted at the failing step and resumes
// there — without re-running the completed steps.
func TestFlowCheckpointResume(t *testing.T) {
mem := store.NewMemoryStore()
var firstCalls, fixed int
steps := []Step{
{Name: "first", Run: func(_ context.Context, in State) (State, error) {
firstCalls++
in.Data = []byte("first-done")
return in, nil
}},
{Name: "flaky", Run: func(_ context.Context, in State) (State, error) {
if fixed == 0 {
return in, errors.New("dependency unavailable")
}
in.Data = []byte("flaky-done")
return in, nil
}},
}
f := New("resumable", WithCheckpoint(StoreCheckpoint(mem, "resumable")), Steps(steps...))
// First run fails at "flaky".
if err := f.Execute(context.Background(), "start"); err == nil {
t.Fatal("expected the run to fail at the flaky step")
}
pend, _ := f.Pending(context.Background())
if len(pend) != 1 {
t.Fatalf("expected 1 pending run, got %d", len(pend))
}
if pend[0].State.Stage != "flaky" {
t.Fatalf("run should be checkpointed at the flaky step, got stage %q", pend[0].State.Stage)
}
runID := pend[0].ID
// The dependency recovers; resume continues from where it stopped.
fixed = 1
if err := f.Resume(context.Background(), runID); err != nil {
t.Fatalf("Resume: %v", err)
}
if firstCalls != 1 {
t.Errorf("completed step should not re-run on resume; first called %d times", firstCalls)
}
if pend, _ := f.Pending(context.Background()); len(pend) != 0 {
t.Errorf("expected no pending runs after a successful resume, got %d", len(pend))
}
}
func TestFlowStepContextIncludesRunInfo(t *testing.T) {
var got ai.RunInfo
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
var ok bool
got, ok = ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from step context")
}
in.Data = []byte("ok")
return in, nil
}}
mem := store.NewMemoryStore()
f := New("correlated",
WithCheckpoint(StoreCheckpoint(mem, "correlated")),
Steps(step),
)
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "agent-run-1", Agent: "planner"})
if err := f.Execute(ctx, "start"); err != nil {
t.Fatalf("Execute: %v", err)
}
if got.Agent != "correlated" {
t.Fatalf("RunInfo.Agent = %q, want correlated", got.Agent)
}
if got.RunID == "" {
t.Fatal("RunInfo.RunID is empty")
}
if got.Flow != "correlated" {
t.Fatalf("RunInfo.Flow = %q, want correlated", got.Flow)
}
if got.ParentID != "agent-run-1" {
t.Fatalf("RunInfo.ParentID = %q, want agent-run-1", got.ParentID)
}
if got.Step != "inspect" {
t.Fatalf("RunInfo.Step = %q, want inspect", got.Step)
}
runs, err := StoreCheckpoint(mem, "correlated").List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 || runs[0].ParentID != "agent-run-1" {
t.Fatalf("persisted parent id = %+v, want agent-run-1", runs)
}
}
func TestFlowResumePendingResumesOldestRunsUntilFailure(t *testing.T) {
mem := store.NewMemoryStore()
ctx := context.Background()
var calls int
step := Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
calls++
if in.String() == "block" {
return in, errors.New("still blocked")
}
in.Data = []byte(in.String() + "-done")
return in, nil
}}
f := New("resume-pending", WithCheckpoint(StoreCheckpoint(mem, "resume-pending")), Steps(step))
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
runs := []Run{
{
ID: "run-ok",
Flow: "resume-pending",
State: State{Stage: "work", Data: []byte("ok")},
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
Status: "failed",
Started: base,
},
{
ID: "run-blocked",
Flow: "resume-pending",
State: State{Stage: "work", Data: []byte("block")},
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
Status: "failed",
Started: base.Add(time.Minute),
},
{
ID: "run-later",
Flow: "resume-pending",
State: State{Stage: "work", Data: []byte("later")},
Steps: []StepRecord{{Name: "work", Status: "failed", Error: "temporary"}},
Status: "failed",
Started: base.Add(2 * time.Minute),
},
}
for _, run := range runs {
if err := f.checkpoint.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
failedRun, err := f.ResumePending(ctx)
if err == nil {
t.Fatal("expected ResumePending to stop at the blocked run")
}
if failedRun != "run-blocked" {
t.Fatalf("failed run = %q, want run-blocked", failedRun)
}
if calls != 2 {
t.Fatalf("ResumePending should stop before later runs; got %d calls", calls)
}
run, ok, err := f.checkpoint.Load(ctx, "run-ok")
if err != nil || !ok {
t.Fatalf("Load(run-ok) ok=%v err=%v", ok, err)
}
if run.Status != "done" || run.State.String() != "ok-done" {
t.Fatalf("run-ok not resumed successfully: %+v", run)
}
run, ok, err = f.checkpoint.Load(ctx, "run-later")
if err != nil || !ok {
t.Fatalf("Load(run-later) ok=%v err=%v", ok, err)
}
if run.Status != "failed" {
t.Fatalf("run-later should not be resumed after a failure, got %+v", run)
}
}
// A flow-level Retry re-runs a failing step until it succeeds.
func TestFlowStepRetry(t *testing.T) {
var attempts int
step := Step{Name: "transient", Run: func(_ context.Context, in State) (State, error) {
attempts++
if attempts < 3 {
return in, errors.New("transient")
}
in.Data = []byte("ok")
return in, nil
}}
f := New("retrying",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "retrying")),
Retry(2), // up to 3 tries
Steps(step),
)
if err := f.Execute(context.Background(), ""); err != nil {
t.Fatalf("Execute: %v", err)
}
if attempts != 3 {
t.Errorf("want 3 attempts with Retry(2), got %d", attempts)
}
}
// A per-step Retry overrides the flow default.
func TestFlowStepRetryOverride(t *testing.T) {
var attempts int
step := Step{Name: "capped", Retry: 1, Run: func(_ context.Context, in State) (State, error) {
attempts++
return in, errors.New("always fails")
}}
f := New("override",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "override")),
Retry(5), // would be 6 tries; the step's Retry:1 caps it at 2
Steps(step),
)
_ = f.Execute(context.Background(), "")
if attempts != 2 {
t.Errorf("per-step Retry(1) should cap tries at 2, got %d", attempts)
}
}
func TestFlowStepRetryBackoffWaitsBetweenAttempts(t *testing.T) {
var attempts int
step := Step{Name: "transient", Run: func(_ context.Context, in State) (State, error) {
attempts++
if attempts == 1 {
return in, errors.New("transient")
}
in.Data = []byte("ok")
return in, nil
}}
f := New("retry-backoff",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "retry-backoff")),
Retry(1),
RetryBackoff(10*time.Millisecond),
Steps(step),
)
start := time.Now()
if err := f.Execute(context.Background(), ""); err != nil {
t.Fatalf("Execute: %v", err)
}
if attempts != 2 {
t.Fatalf("want 2 attempts, got %d", attempts)
}
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
t.Fatalf("retry backoff was not observed; elapsed %s", elapsed)
}
}
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
step := Step{Name: "cancelbackoff", Run: func(_ context.Context, in State) (State, error) {
attempts++
cancel()
return in, errors.New("transient")
}}
f := New("cancel-backoff",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "cancel-backoff")),
Retry(1),
RetryBackoff(time.Hour),
Steps(step),
)
err := f.Execute(ctx, "")
if err == nil {
t.Fatal("expected the canceled run to fail")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("want a context.Canceled error, got %v", err)
}
if attempts != 1 {
t.Errorf("cancellation should stop during backoff before retrying, got %d attempts", attempts)
}
}
// A canceled run stops retrying immediately instead of burning the whole
// retry budget, and surfaces the context error.
func TestFlowStepRetryStopsOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var attempts int
step := Step{Name: "cancelaware", Run: func(_ context.Context, in State) (State, error) {
attempts++
cancel() // the run is canceled while this step is in flight
return in, errors.New("transient")
}}
f := New("cancelretry",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "cancelretry")),
Retry(5), // would be 6 tries without the cancellation check
Steps(step),
)
err := f.Execute(ctx, "")
if err == nil {
t.Fatal("expected the canceled run to fail")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("want a context.Canceled error, got %v", err)
}
if attempts != 1 {
t.Errorf("cancellation should stop retries after the first attempt, got %d", attempts)
}
}
func TestFlowStepNamesMustBeUnique(t *testing.T) {
step := Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
return in, nil
}}
f := New("duplicate-steps",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "duplicate-steps")),
Steps(step, step),
)
err := f.Execute(context.Background(), "")
if err == nil {
t.Fatal("expected duplicate step names to fail validation")
}
if got, want := err.Error(), `flow: duplicate step name "work"`; got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
func TestFlowStepNamesMustBeNonEmpty(t *testing.T) {
f := New("empty-step-name",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "empty-step-name")),
Steps(Step{Name: "", Run: func(_ context.Context, in State) (State, error) {
return in, nil
}}),
)
err := f.Execute(context.Background(), "")
if err == nil {
t.Fatal("expected an empty step name to fail validation")
}
if got, want := err.Error(), "flow: step 0 has an empty name"; got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
// A step with no Run function is reported as a configuration error rather
// than panicking the run.
func TestFlowStepNilRun(t *testing.T) {
f := New("nilstep",
WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "nilstep")),
Steps(Step{Name: "missing"}),
)
err := f.Execute(context.Background(), "")
if err == nil {
t.Fatal("expected an error for a step with no Run function")
}
}
func TestStoreCheckpointListReturnsRunsInStartedOrder(t *testing.T) {
cp := StoreCheckpoint(store.NewMemoryStore(), "ordered")
ctx := context.Background()
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
runs := []Run{
{ID: "run-c", Flow: "ordered", Status: "failed", Started: base.Add(2 * time.Minute)},
{ID: "run-a", Flow: "ordered", Status: "failed", Started: base},
{ID: "run-b", Flow: "ordered", Status: "failed", Started: base.Add(time.Minute)},
}
for _, run := range runs {
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
got, err := cp.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 3 {
t.Fatalf("List returned %d runs, want 3", len(got))
}
want := []string{"run-a", "run-b", "run-c"}
for i, id := range want {
if got[i].ID != id {
t.Fatalf("run %d = %q, want %q (all runs: %+v)", i, got[i].ID, id, got)
}
}
}
func TestStoreCheckpointHonorsCanceledContext(t *testing.T) {
cp := StoreCheckpoint(store.NewMemoryStore(), "canceled")
ctx, cancel := context.WithCancel(context.Background())
cancel()
run := Run{ID: "canceled", Started: time.Now()}
if err := cp.Save(ctx, run); !errors.Is(err, context.Canceled) {
t.Fatalf("Save error = %v, want context.Canceled", err)
}
if _, ok, err := cp.Load(ctx, run.ID); !errors.Is(err, context.Canceled) || ok {
t.Fatalf("Load ok, error = %v, %v; want false, context.Canceled", ok, err)
}
if err := cp.Delete(ctx, run.ID); !errors.Is(err, context.Canceled) {
t.Fatalf("Delete error = %v, want context.Canceled", err)
}
if _, err := cp.List(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("List error = %v, want context.Canceled", err)
}
}
type failingCheckpoint struct {
err error
}
func (c failingCheckpoint) Save(context.Context, Run) error { return c.err }
func (c failingCheckpoint) Load(context.Context, string) (Run, bool, error) {
return Run{}, false, c.err
}
func (c failingCheckpoint) Delete(context.Context, string) error { return c.err }
func (c failingCheckpoint) List(context.Context) ([]Run, error) { return nil, c.err }
func TestFlowCheckpointSaveFailureStopsRun(t *testing.T) {
checkpointErr := errors.New("checkpoint unavailable")
var ran bool
f := New("checkpoint-fails",
WithCheckpoint(failingCheckpoint{err: checkpointErr}),
Steps(Step{Name: "work", Run: func(_ context.Context, in State) (State, error) {
ran = true
return in, nil
}}),
)
err := f.Execute(context.Background(), "start")
if !errors.Is(err, checkpointErr) {
t.Fatalf("Execute error = %v, want checkpoint error", err)
}
if ran {
t.Fatal("step ran even though the in-progress checkpoint failed")
}
}
func TestFlowDeleteOnSuccessFailureIsReturned(t *testing.T) {
checkpointErr := errors.New("delete unavailable")
cp := &deleteFailCheckpoint{Checkpoint: StoreCheckpoint(store.NewMemoryStore(), "delete-fails"), err: checkpointErr}
f := New("delete-fails",
WithCheckpoint(cp),
DeleteOnSuccess(),
Steps(appendStep("work")),
)
err := f.Execute(context.Background(), "")
if !errors.Is(err, checkpointErr) {
t.Fatalf("Execute error = %v, want delete error", err)
}
}
type deleteFailCheckpoint struct {
Checkpoint
err error
}
func (c *deleteFailCheckpoint) Delete(context.Context, string) error { return c.err }
func TestStateSetScan(t *testing.T) {
var s State
type payload struct {
Email string `json:"email"`
}
if err := s.Set(payload{Email: "a@b.com"}); err != nil {
t.Fatalf("Set: %v", err)
}
var got payload
if err := s.Scan(&got); err != nil {
t.Fatalf("Scan: %v", err)
}
if got.Email != "a@b.com" {
t.Errorf("round-trip failed: %+v", got)
}
}
func TestFlowFailureRecordsErrorKind(t *testing.T) {
cp := StoreCheckpoint(store.NewMemoryStore(), "failure-kind")
f := New("failure-kind",
WithCheckpoint(cp),
Steps(Step{Name: "limited", Run: func(_ context.Context, in State) (State, error) {
return in, errors.New("rate limit exceeded")
}}),
)
err := f.Execute(context.Background(), "payload")
if err == nil {
t.Fatal("Execute error = nil, want failure")
}
runs, listErr := cp.List(context.Background())
if listErr != nil {
t.Fatalf("List: %v", listErr)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
if got := runs[0].Steps[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
t.Fatalf("step error kind = %q, want %q", got, ai.ErrorKindRateLimited)
}
results := f.Results()
if len(results) != 1 {
t.Fatalf("results = %d, want 1", len(results))
}
if got := results[0].ErrorKind; got != string(ai.ErrorKindRateLimited) {
t.Fatalf("result error kind = %q, want %q", got, ai.ErrorKindRateLimited)
}
}
+100
View File
@@ -0,0 +1,100 @@
package flow
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
func TestFlowStepVerificationRetriesWithFeedback(t *testing.T) {
var attempts int
var feedback []string
step := Step{
Name: "draft",
Retry: 1,
Run: func(ctx context.Context, in State) (State, error) {
attempts++
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from verified step")
}
feedback = append(feedback, info.VerificationFeedback)
if info.VerificationFeedback == "add evidence" {
in.Data = []byte("answer with evidence")
} else {
in.Data = []byte("answer")
}
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
if out.String() == "answer with evidence" {
return Verification{Passed: true, Feedback: "meets rubric"}, nil
}
return Verification{Feedback: "add evidence"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified")
f := New("verified", WithCheckpoint(cp), Steps(step))
if err := f.Execute(context.Background(), "question"); err != nil {
t.Fatal(err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
if len(feedback) != 2 || feedback[0] != "" || feedback[1] != "add evidence" {
t.Fatalf("feedback = %#v, want empty then verifier feedback", feedback)
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if stepRecord.Status != "done" || stepRecord.Attempts != 2 || stepRecord.VerificationStatus != "passed" || stepRecord.VerificationNote != "meets rubric" {
t.Fatalf("step record = %#v", stepRecord)
}
}
func TestFlowStepVerificationFailureIsCheckpointed(t *testing.T) {
step := Step{
Name: "grade",
Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("bad")
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
return Verification{Feedback: "missing citation"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified-fail")
f := New("verified-fail", WithCheckpoint(cp), Steps(step))
err := f.Execute(context.Background(), "question")
if err == nil {
t.Fatal("Execute succeeded, want verification failure")
}
var verr *VerificationError
if !errors.As(err, &verr) {
t.Fatalf("error = %T %v, want VerificationError", err, err)
}
if verr.Feedback != "missing citation" {
t.Fatalf("feedback = %q, want missing citation", verr.Feedback)
}
runs, listErr := cp.List(context.Background())
if listErr != nil {
t.Fatal(listErr)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if runs[0].Status != "failed" || stepRecord.VerificationStatus != "failed" || stepRecord.VerificationNote != "missing citation" {
t.Fatalf("run = %#v step = %#v", runs[0], stepRecord)
}
}
+191
View File
@@ -0,0 +1,191 @@
package flow
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
)
// Grader checks a step output against a rubric. It returns pass=true when the
// output is acceptable; otherwise feedback should explain what the next attempt
// should fix.
type Grader func(ctx context.Context, out State) (pass bool, feedback string, err error)
// VerifyOptions configure Verify.
type VerifyOptions struct {
// MaxAttempts bounds how many times the body can run. Default 2.
MaxAttempts int
// Backoff waits between failed grades. Zero means retry immediately.
Backoff time.Duration
// FeedbackField is the JSON field used to thread grader feedback into the
// next attempt's input. Default "feedback".
FeedbackField string
}
// VerifyOption configures Verify.
type VerifyOption func(*VerifyOptions)
// VerifyMaxAttempts sets the total attempt budget for Verify. Values <= 0 use
// the default of 2.
func VerifyMaxAttempts(n int) VerifyOption { return func(o *VerifyOptions) { o.MaxAttempts = n } }
// VerifyBackoff sets the delay between failed verification attempts.
func VerifyBackoff(d time.Duration) VerifyOption { return func(o *VerifyOptions) { o.Backoff = d } }
// VerifyFeedbackField sets the JSON field used to pass grader feedback to the
// next body attempt. Empty values use "feedback".
func VerifyFeedbackField(field string) VerifyOption {
return func(o *VerifyOptions) { o.FeedbackField = field }
}
// Verify runs body, grades its output, and retries with grader feedback threaded
// into the next input until the grader passes or MaxAttempts is exhausted. It is
// a StepFunc, so it composes directly as Step.Run with Loop, LLM, Call, Agent, or
// any code-defined step.
//
// On a failed grade, Verify adds the feedback to the next attempt's input as a
// JSON field named "feedback" (or VerifyFeedbackField). When all attempts fail,
// it returns the last output without error, annotated with verification fields so
// the run can keep the bounded failure outcome in its state:
// "verification_passed": false, "verification_feedback", and
// "verification_attempts".
func Verify(body StepFunc, grader Grader, opts ...VerifyOption) StepFunc {
o := VerifyOptions{MaxAttempts: 2, FeedbackField: "feedback"}
for _, op := range opts {
op(&o)
}
if o.MaxAttempts <= 0 {
o.MaxAttempts = 2
}
if o.FeedbackField == "" {
o.FeedbackField = "feedback"
}
return func(ctx context.Context, in State) (State, error) {
if body == nil {
return in, fmt.Errorf("flow: Verify requires a body step")
}
if grader == nil {
return in, fmt.Errorf("flow: Verify requires a grader")
}
cur := in
last := in
feedback := ""
for attempt := 1; attempt <= o.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return last, err
}
if feedback != "" {
var err error
cur, err = stateWithField(cur, o.FeedbackField, feedback)
if err != nil {
return last, err
}
}
out, err := body(ctx, cur)
if err != nil {
return last, fmt.Errorf("verify attempt %d: %w", attempt, err)
}
last = out
pass, fb, err := grader(ctx, out)
if err != nil {
return last, fmt.Errorf("verify grade attempt %d: %w", attempt, err)
}
if pass {
return stateWithVerification(out, true, fb, attempt)
}
feedback = fb
cur = in
if attempt < o.MaxAttempts && o.Backoff > 0 {
select {
case <-time.After(o.Backoff):
case <-ctx.Done():
return last, ctx.Err()
}
}
}
return stateWithVerification(last, false, feedback, o.MaxAttempts)
}
}
// LLMGrader returns a grader that asks the flow model to judge the latest output
// against rubric. The model should answer with pass/fail plus short feedback.
// It reuses the flow's configured model, so it must run inside a flow.
func LLMGrader(rubric string) Grader {
return func(ctx context.Context, out State) (bool, string, error) {
d := depsFrom(ctx)
if d == nil || d.model == nil {
return false, "", fmt.Errorf("flow: LLMGrader requires a flow model (set Provider/APIKey)")
}
prompt := fmt.Sprintf("Grade the latest result against this rubric:\n%s\n\nLatest result:\n%s\n\nAnswer with PASS or FAIL on the first line, followed by one short feedback sentence.", rubric, out.String())
resp, err := d.model.Generate(ctx, &ai.Request{Prompt: prompt})
if err != nil {
return false, "", err
}
reply := resp.Answer
if reply == "" {
reply = resp.Reply
}
return parseGrade(reply)
}
}
func parseGrade(reply string) (bool, string, error) {
text := strings.TrimSpace(reply)
if text == "" {
return false, "", fmt.Errorf("flow: LLMGrader returned an empty grade")
}
lines := strings.SplitN(text, "\n", 2)
first := strings.ToLower(strings.TrimSpace(lines[0]))
feedback := ""
if len(lines) > 1 {
feedback = strings.TrimSpace(lines[1])
}
pass := strings.HasPrefix(first, "pass") || isAffirmative(first)
if !pass && feedback == "" {
feedback = text
}
return pass, feedback, nil
}
func stateWithField(s State, field, value string) (State, error) {
var obj map[string]any
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
obj[field] = value
return stateWithObject(s, obj)
}
obj = map[string]any{field: value}
if len(s.Data) > 0 {
obj["data"] = s.String()
}
return stateWithObject(s, obj)
}
func stateWithVerification(s State, passed bool, feedback string, attempts int) (State, error) {
var obj map[string]any
if len(s.Data) > 0 && json.Unmarshal(s.Data, &obj) == nil && obj != nil {
obj["verification_passed"] = passed
obj["verification_feedback"] = feedback
obj["verification_attempts"] = attempts
return stateWithObject(s, obj)
}
obj = map[string]any{
"data": s.String(),
"verification_passed": passed,
"verification_feedback": feedback,
"verification_attempts": attempts,
}
return stateWithObject(s, obj)
}
func stateWithObject(s State, obj map[string]any) (State, error) {
b, err := json.Marshal(obj)
if err != nil {
return s, err
}
s.Data = b
return s, nil
}
+96
View File
@@ -0,0 +1,96 @@
package flow
import (
"context"
"strings"
"testing"
)
func TestVerifyPassesFirstTry(t *testing.T) {
attempts := 0
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
in.Data = []byte(`{"answer":"ok"}`)
return in, nil
}, func(context.Context, State) (bool, string, error) {
return true, "looks good", nil
}, VerifyMaxAttempts(3))
out, err := step(context.Background(), State{})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 1 {
t.Fatalf("body attempts = %d, want 1", attempts)
}
var got map[string]any
if err := out.Scan(&got); err != nil {
t.Fatalf("scan output: %v", err)
}
if got["verification_passed"] != true {
t.Fatalf("verification_passed = %v, want true", got["verification_passed"])
}
}
func TestVerifyRetriesWithFeedback(t *testing.T) {
attempts := 0
var secondInput map[string]string
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
if attempts == 2 {
if err := in.Scan(&secondInput); err != nil {
t.Fatalf("scan second input: %v", err)
}
}
in.Data = []byte(`{"answer":"draft"}`)
return in, nil
}, func(_ context.Context, _ State) (bool, string, error) {
return attempts >= 2, "include citations", nil
}, VerifyMaxAttempts(3))
out, err := step(context.Background(), State{Data: []byte(`{"topic":"agents"}`)})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 2 {
t.Fatalf("body attempts = %d, want 2", attempts)
}
if secondInput["feedback"] != "include citations" {
t.Fatalf("feedback = %q, want include citations", secondInput["feedback"])
}
if !strings.Contains(out.String(), `"verification_passed":true`) {
t.Fatalf("output missing successful verification annotation: %s", out.String())
}
}
func TestVerifyExhaustsAttemptsReturnsLastOutput(t *testing.T) {
attempts := 0
step := Verify(func(_ context.Context, in State) (State, error) {
attempts++
in.Data = []byte(`{"answer":"still wrong"}`)
return in, nil
}, func(context.Context, State) (bool, string, error) {
return false, "try again", nil
}, VerifyMaxAttempts(2))
out, err := step(context.Background(), State{})
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if attempts != 2 {
t.Fatalf("body attempts = %d, want 2", attempts)
}
var got map[string]any
if err := out.Scan(&got); err != nil {
t.Fatalf("scan output: %v", err)
}
if got["verification_passed"] != false {
t.Fatalf("verification_passed = %v, want false", got["verification_passed"])
}
if got["verification_feedback"] != "try again" {
t.Fatalf("verification_feedback = %v, want try again", got["verification_feedback"])
}
if got["verification_attempts"] != float64(2) {
t.Fatalf("verification_attempts = %v, want 2", got["verification_attempts"])
}
}