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
346 lines
9.6 KiB
Go
346 lines
9.6 KiB
Go
// 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)
|
|
}
|
|
}
|