Files
micro--go-micro/flow/steps.go
T
wehub-resource-sync e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:40:33 +08:00

636 lines
18 KiB
Go

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] + "…"
}