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
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:
+212
@@ -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]
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 = ®istry.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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user