mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-10 05:14:48 +08:00
### What problem does this PR solve? - Tools management - Pregel engine wrapper for better usage - UT race - Coding style ### Type of change - [x] Refactoring
1194 lines
36 KiB
Go
1194 lines
36 KiB
Go
// Package graph provides graph building capabilities for Harness-Go.
|
|
package graph
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
|
|
"github.com/google/uuid"
|
|
"ragflow/internal/harness/graph/channels"
|
|
"ragflow/internal/harness/graph/checkpoint"
|
|
"ragflow/internal/harness/graph/constants"
|
|
"ragflow/internal/harness/graph/errors"
|
|
"ragflow/internal/harness/graph/types"
|
|
)
|
|
|
|
// Node represents a node in the graph. Each node is a callable function that
|
|
// receives the current shared state and returns a (possibly modified) state.
|
|
// Nodes are connected by edges which determine execution order.
|
|
type Node struct {
|
|
// Name is a unique identifier for this node within the graph.
|
|
Name string
|
|
// Function is the node's executable body. It receives context and state,
|
|
// and returns the new state or an error.
|
|
Function types.NodeFunc
|
|
// Triggers lists channel names this node reads from.
|
|
Triggers []string
|
|
// Writes lists channel names this node writes to.
|
|
Writes []string
|
|
// RetryPolicy configures automatic retry for this node.
|
|
RetryPolicy *types.RetryPolicy
|
|
// Tags are opaque labels for filtering and debugging.
|
|
Tags []string
|
|
// Metadata holds arbitrary key-value pairs for tooling.
|
|
Metadata map[string]interface{}
|
|
// FieldMapping specifies field-level routing for this node's output.
|
|
// Used by the engine to route only specific fields through data edges.
|
|
FieldMapping []FieldMapping
|
|
}
|
|
|
|
// Edge is a directed connection between two nodes. After the From node
|
|
// completes, execution proceeds to the To node. Use constants.Start and
|
|
// constants.End for the virtual start/end nodes.
|
|
//
|
|
// Example:
|
|
//
|
|
// sg.AddEdge("node_a", "node_b") // node_a always flows to node_b
|
|
// sg.AddEdge("node_b", "__end__") // node_b is a terminal node
|
|
type Edge struct {
|
|
From string
|
|
To string
|
|
}
|
|
|
|
// FieldMapping specifies how a field from a source node's output is mapped
|
|
// to a target node's input. Supports dotted paths like "a.b.c" for nested access.
|
|
//
|
|
// Example:
|
|
//
|
|
// FieldMapping{From: "response.text", To: "input.query"}
|
|
type FieldMapping struct {
|
|
From string // source field path (dotted notation, empty = pass entire state)
|
|
To string // target field path (dotted notation, empty = set at root)
|
|
}
|
|
|
|
// DataEdge is a directed data-flow connection with field-level mapping.
|
|
// It allows fine-grained control over which fields flow between nodes.
|
|
// Unlike Edge (control flow), DataEdge only routes data without affecting
|
|
// execution order. Control flow is determined by Edge/conditionalEdge alone.
|
|
type DataEdge struct {
|
|
From string
|
|
To string
|
|
Mapping []FieldMapping
|
|
}
|
|
|
|
// ConditionalEdge allows routing to different nodes based on a condition
|
|
// function. The Condition function is evaluated after the From node completes;
|
|
// its return value is looked up in Mapping to determine the next node.
|
|
//
|
|
// Example:
|
|
//
|
|
// sg.AddConditionalEdges("router",
|
|
// func(ctx context.Context, state any) (any, error) {
|
|
// return state.(MyState).Route, nil
|
|
// },
|
|
// map[string]string{
|
|
// "path_a": "node_a",
|
|
// "path_b": "node_b",
|
|
// "__end__": "__end__",
|
|
// },
|
|
// )
|
|
type ConditionalEdge struct {
|
|
From string
|
|
Condition types.EdgeFunc
|
|
// Mapping from condition result to target node name.
|
|
Mapping map[string]string
|
|
}
|
|
|
|
// Branch provides a higher-level conditional edge. The Condition function
|
|
// is evaluated, and Then receives the result to produce zero or more target
|
|
// node names. Unlike ConditionalEdge, Branch supports single-source fan-out.
|
|
type Branch struct {
|
|
From string
|
|
Condition types.EdgeFunc
|
|
// Then is called with the condition result to determine next nodes.
|
|
Then func(interface{}) []string
|
|
}
|
|
|
|
// Send represents a dynamic node invocation. It is used with StateGraph's
|
|
// dynamic routing to invoke a named node with a specific argument, bypassing
|
|
// the normal state channel. This enables map-reduce and fan-out patterns
|
|
// where different nodes receive different subsets of the state.
|
|
type Send struct {
|
|
Node string
|
|
Arg interface{}
|
|
}
|
|
|
|
// StateGraph is a graph whose nodes communicate by reading and writing to a shared state.
|
|
//
|
|
// Nodes execute sequentially or conditionally based on directed edges. Each node
|
|
// receives the current state (a map or struct matching the schema) and returns
|
|
// an updated state. The framework merges returned values into channels using
|
|
// configured reducers.
|
|
//
|
|
// Usage:
|
|
//
|
|
// // Define state schema
|
|
// type MyState struct { Messages []string }
|
|
//
|
|
// builder := NewStateGraph(MyState{})
|
|
// builder.AddNode("agent", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
// s := state.(MyState)
|
|
// s.Messages = append(s.Messages, "hello")
|
|
// return s, nil
|
|
// })
|
|
// builder.AddEdge("__start__", "agent")
|
|
// builder.AddEdge("agent", "__end__")
|
|
// compiled, err := builder.Compile()
|
|
type StateGraph struct {
|
|
// Nodes in the graph
|
|
nodes map[string]*Node
|
|
// Edges between nodes
|
|
edges []*Edge
|
|
// Data edges for field-level routing
|
|
dataEdges []*DataEdge
|
|
// Conditional edges
|
|
conditionalEdges []*ConditionalEdge
|
|
// Branches
|
|
branches []*Branch
|
|
// Entry point of the graph
|
|
entryPoint string
|
|
// Finish points of the graph
|
|
finishPoints []string
|
|
// Channel definitions for the state schema
|
|
channels map[string]channels.Channel
|
|
// Reducer functions for channels
|
|
reducers map[string]types.ReducerFunc
|
|
// State schema type
|
|
stateSchema interface{}
|
|
// Input schema type
|
|
inputSchema interface{}
|
|
// Output schema type
|
|
outputSchema interface{}
|
|
// NodeTriggerMode controls how nodes are triggered for execution.
|
|
NodeTriggerMode types.NodeTriggerMode
|
|
}
|
|
|
|
// NewStateGraph creates a new StateGraph with the given state schema.
|
|
// The stateSchema defines the structure of the shared state.
|
|
func NewStateGraph(stateSchema interface{}) *StateGraph {
|
|
return &StateGraph{
|
|
nodes: make(map[string]*Node),
|
|
edges: make([]*Edge, 0),
|
|
conditionalEdges: make([]*ConditionalEdge, 0),
|
|
branches: make([]*Branch, 0),
|
|
finishPoints: make([]string, 0),
|
|
channels: make(map[string]channels.Channel),
|
|
reducers: make(map[string]types.ReducerFunc),
|
|
stateSchema: stateSchema,
|
|
inputSchema: stateSchema,
|
|
outputSchema: stateSchema,
|
|
}
|
|
}
|
|
|
|
// WithInputSchema sets the input schema for the graph.
|
|
func (g *StateGraph) WithInputSchema(schema interface{}) *StateGraph {
|
|
g.inputSchema = schema
|
|
return g
|
|
}
|
|
|
|
// WithOutputSchema sets the output schema for the graph.
|
|
func (g *StateGraph) WithOutputSchema(schema interface{}) *StateGraph {
|
|
g.outputSchema = schema
|
|
return g
|
|
}
|
|
|
|
// AddNode adds a node to the graph.
|
|
func (g *StateGraph) AddNode(name string, fn types.NodeFunc) *Node {
|
|
node := &Node{
|
|
Name: name,
|
|
Function: fn,
|
|
Triggers: make([]string, 0),
|
|
Writes: make([]string, 0),
|
|
Tags: make([]string, 0),
|
|
Metadata: make(map[string]interface{}),
|
|
}
|
|
g.nodes[name] = node
|
|
return node
|
|
}
|
|
|
|
// AddNodeWithOptions adds a node with options.
|
|
func (g *StateGraph) AddNodeWithOptions(name string, fn types.NodeFunc, opts NodeOptions) *Node {
|
|
// Apply StatePre/StatePost wrappers around the node function.
|
|
if opts.StatePre != nil || opts.StatePost != nil {
|
|
orig := fn
|
|
pre := opts.StatePre
|
|
post := opts.StatePost
|
|
fn = func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
if pre != nil {
|
|
var err error
|
|
state, err = pre(ctx, state)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state pre-handler for '%s': %w", name, err)
|
|
}
|
|
}
|
|
out, err := orig(ctx, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if post != nil {
|
|
out, err = post(ctx, out)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state post-handler for '%s': %w", name, err)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
node := g.AddNode(name, fn)
|
|
if opts.RetryPolicy != nil {
|
|
node.RetryPolicy = opts.RetryPolicy
|
|
}
|
|
if len(opts.Tags) > 0 {
|
|
node.Tags = append(node.Tags, opts.Tags...)
|
|
}
|
|
if len(opts.Metadata) > 0 {
|
|
for k, v := range opts.Metadata {
|
|
node.Metadata[k] = v
|
|
}
|
|
}
|
|
if len(opts.Triggers) > 0 {
|
|
node.Triggers = append(node.Triggers, opts.Triggers...)
|
|
}
|
|
if len(opts.Writes) > 0 {
|
|
node.Writes = append(node.Writes, opts.Writes...)
|
|
}
|
|
if len(opts.FieldMapping) > 0 {
|
|
node.FieldMapping = append(node.FieldMapping, opts.FieldMapping...)
|
|
}
|
|
return node
|
|
}
|
|
|
|
// NodeOptions contains options for adding a node.
|
|
type NodeOptions struct {
|
|
RetryPolicy *types.RetryPolicy
|
|
Tags []string
|
|
Metadata map[string]interface{}
|
|
Triggers []string
|
|
Writes []string
|
|
FieldMapping []FieldMapping // field-level routing for this node's output
|
|
StatePre types.NodeFunc // transforms state BEFORE node execution
|
|
StatePost types.NodeFunc // transforms state AFTER node execution
|
|
}
|
|
|
|
// WithStatePreHandler wraps the node with a pre-execution state transform.
|
|
// The handler receives the incoming state and can modify it before the node runs.
|
|
func WithStatePreHandler(fn types.NodeFunc) func(*NodeOptions) {
|
|
return func(opts *NodeOptions) { opts.StatePre = fn }
|
|
}
|
|
|
|
// WithStatePostHandler wraps the node with a post-execution state transform.
|
|
// The handler receives the node's output state and can modify it before it flows downstream.
|
|
func WithStatePostHandler(fn types.NodeFunc) func(*NodeOptions) {
|
|
return func(opts *NodeOptions) { opts.StatePost = fn }
|
|
}
|
|
|
|
// WithFieldMapping sets field-level routing for this node's output.
|
|
func WithFieldMapping(mappings ...FieldMapping) func(*NodeOptions) {
|
|
return func(opts *NodeOptions) { opts.FieldMapping = append(opts.FieldMapping, mappings...) }
|
|
}
|
|
|
|
// MapFields is a convenience function to create a FieldMapping from a source path to a target path.
|
|
func MapFields(from, to string) FieldMapping {
|
|
return FieldMapping{From: from, To: to}
|
|
}
|
|
|
|
// MapTo is a convenience function to create a FieldMapping that maps the entire output to a target path.
|
|
func MapTo(to string) FieldMapping {
|
|
return FieldMapping{To: to}
|
|
}
|
|
|
|
// AddEdge adds an edge between two nodes.
|
|
func (g *StateGraph) AddEdge(from, to string) error {
|
|
if _, ok := g.nodes[from]; !ok && from != constants.Start {
|
|
return &errors.NodeNotFoundError{NodeName: from}
|
|
}
|
|
if _, ok := g.nodes[to]; !ok && to != constants.End {
|
|
return &errors.NodeNotFoundError{NodeName: to}
|
|
}
|
|
|
|
g.edges = append(g.edges, &Edge{From: from, To: to})
|
|
|
|
// If this is an edge from Start, set entry point to the target node
|
|
if from == constants.Start {
|
|
g.entryPoint = to
|
|
}
|
|
|
|
// If this is an edge to End, set the source as a finish point
|
|
if to == constants.End {
|
|
found := false
|
|
for _, fp := range g.finishPoints {
|
|
if fp == from {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
g.finishPoints = append(g.finishPoints, from)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddConditionalEdges adds conditional edges from a node.
|
|
func (g *StateGraph) AddConditionalEdges(from string, condition types.EdgeFunc, mapping map[string]string) error {
|
|
if _, ok := g.nodes[from]; !ok {
|
|
return &errors.NodeNotFoundError{NodeName: from}
|
|
}
|
|
|
|
// Validate all targets exist
|
|
for _, target := range mapping {
|
|
if _, ok := g.nodes[target]; !ok && target != constants.End {
|
|
return &errors.NodeNotFoundError{NodeName: target}
|
|
}
|
|
}
|
|
|
|
g.conditionalEdges = append(g.conditionalEdges, &ConditionalEdge{
|
|
From: from,
|
|
Condition: condition,
|
|
Mapping: mapping,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// AddBranch adds a branch from a node.
|
|
func (g *StateGraph) AddBranch(from string, condition types.EdgeFunc, then func(interface{}) []string) error {
|
|
if _, ok := g.nodes[from]; !ok {
|
|
return &errors.NodeNotFoundError{NodeName: from}
|
|
}
|
|
|
|
g.branches = append(g.branches, &Branch{
|
|
From: from,
|
|
Condition: condition,
|
|
Then: then,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// AddDataEdge adds a data-flow edge with optional field-level mappings between two nodes.
|
|
// Unlike AddEdge (control flow), AddDataEdge only routes data without affecting execution order.
|
|
func (g *StateGraph) AddDataEdge(from, to string, mappings ...FieldMapping) error {
|
|
if _, ok := g.nodes[from]; !ok && from != constants.Start {
|
|
return &errors.NodeNotFoundError{NodeName: from}
|
|
}
|
|
if _, ok := g.nodes[to]; !ok && to != constants.End {
|
|
return &errors.NodeNotFoundError{NodeName: to}
|
|
}
|
|
g.dataEdges = append(g.dataEdges, &DataEdge{From: from, To: to, Mapping: mappings})
|
|
return nil
|
|
}
|
|
|
|
// GetDataEdges returns all data edges in the graph.
|
|
func (g *StateGraph) GetDataEdges() []*DataEdge {
|
|
return g.dataEdges
|
|
}
|
|
|
|
// SetEntryPoint sets the entry point of the graph.
|
|
func (g *StateGraph) SetEntryPoint(node string) error {
|
|
if _, ok := g.nodes[node]; !ok {
|
|
return &errors.NodeNotFoundError{NodeName: node}
|
|
}
|
|
g.entryPoint = node
|
|
return nil
|
|
}
|
|
|
|
// SetFinishPoint sets a finish point of the graph.
|
|
func (g *StateGraph) SetFinishPoint(node string) error {
|
|
if _, ok := g.nodes[node]; !ok {
|
|
return &errors.NodeNotFoundError{NodeName: node}
|
|
}
|
|
g.finishPoints = append(g.finishPoints, node)
|
|
return nil
|
|
}
|
|
|
|
// AddChannel adds a channel definition to the state schema.
|
|
func (g *StateGraph) AddChannel(name string, channel channels.Channel) {
|
|
channel.SetKey(name)
|
|
g.channels[name] = channel
|
|
}
|
|
|
|
// SetReducer sets a reducer function for a channel.
|
|
// If the channel exists, it wraps it with a ReducerChannel.
|
|
func (g *StateGraph) SetReducer(channelName string, reducer types.ReducerFunc) {
|
|
if channel, ok := g.channels[channelName]; ok {
|
|
// Wrap existing channel with reducer
|
|
g.channels[channelName] = channels.NewReducerChannel(channel, reducer)
|
|
}
|
|
g.reducers[channelName] = reducer
|
|
}
|
|
|
|
// AddChannelWithReducer adds a channel definition with a reducer function.
|
|
func (g *StateGraph) AddChannelWithReducer(name string, channel channels.Channel, reducer types.ReducerFunc) {
|
|
channel.SetKey(name)
|
|
if reducer != nil {
|
|
// Wrap channel with reducer
|
|
g.channels[name] = channels.NewReducerChannel(channel, reducer)
|
|
g.reducers[name] = reducer
|
|
} else {
|
|
g.channels[name] = channel
|
|
}
|
|
}
|
|
|
|
// GetNode returns a node by name.
|
|
func (g *StateGraph) GetNode(name string) (*Node, bool) {
|
|
node, ok := g.nodes[name]
|
|
return node, ok
|
|
}
|
|
|
|
// GetNodes returns all nodes.
|
|
func (g *StateGraph) GetNodes() map[string]*Node {
|
|
return g.nodes
|
|
}
|
|
|
|
// GetEdges returns all edges.
|
|
func (g *StateGraph) GetEdges() []*Edge {
|
|
return g.edges
|
|
}
|
|
|
|
// GetChannels returns all channels.
|
|
func (g *StateGraph) GetChannels() map[string]channels.Channel {
|
|
return g.channels
|
|
}
|
|
|
|
// GetEntryPoint returns the entry point node name.
|
|
func (g *StateGraph) GetEntryPoint() string {
|
|
return g.entryPoint
|
|
}
|
|
|
|
// GetConditionalEdges returns all conditional edges.
|
|
func (g *StateGraph) GetConditionalEdges() []*ConditionalEdge {
|
|
return g.conditionalEdges
|
|
}
|
|
|
|
// GetBranches returns all branches.
|
|
func (g *StateGraph) GetBranches() []*Branch {
|
|
return g.branches
|
|
}
|
|
|
|
// Validate validates the graph structure.
|
|
func (g *StateGraph) Validate() error {
|
|
if g.entryPoint == "" {
|
|
return fmt.Errorf("no entry point set")
|
|
}
|
|
|
|
if len(g.finishPoints) == 0 {
|
|
return fmt.Errorf("no finish points set")
|
|
}
|
|
|
|
// Check that all nodes are reachable
|
|
reachable := g.computeReachable()
|
|
for name := range g.nodes {
|
|
if !reachable[name] {
|
|
return fmt.Errorf("node %s is not reachable from entry point", name)
|
|
}
|
|
}
|
|
|
|
// Validate state schema
|
|
if err := g.ValidateStateSchema(); err != nil {
|
|
return fmt.Errorf("state schema validation failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// computeReachable computes all reachable nodes from the entry point.
|
|
func (g *StateGraph) computeReachable() map[string]bool {
|
|
reachable := make(map[string]bool)
|
|
if g.entryPoint == "" {
|
|
return reachable
|
|
}
|
|
|
|
queue := []string{g.entryPoint}
|
|
reachable[g.entryPoint] = true
|
|
|
|
for len(queue) > 0 {
|
|
current := queue[0]
|
|
queue = queue[1:]
|
|
|
|
// Follow regular edges
|
|
for _, edge := range g.edges {
|
|
if edge.From == current && !reachable[edge.To] && edge.To != constants.End {
|
|
reachable[edge.To] = true
|
|
queue = append(queue, edge.To)
|
|
}
|
|
}
|
|
|
|
// Follow conditional edges - all targets are potentially reachable
|
|
for _, condEdge := range g.conditionalEdges {
|
|
if condEdge.From == current {
|
|
for _, target := range condEdge.Mapping {
|
|
if _, ok := g.nodes[target]; ok && !reachable[target] && target != constants.End {
|
|
reachable[target] = true
|
|
queue = append(queue, target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: branches are truly dynamic and can't be statically verified
|
|
}
|
|
|
|
return reachable
|
|
}
|
|
|
|
// configureChannelsFromSchema configures channels and reducers based on state schema annotations.
|
|
func (g *StateGraph) configureChannelsFromSchema() error {
|
|
// Get field information from schema
|
|
fieldInfos, err := g.GetStateSchemaInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Configure channels and reducers for each field
|
|
for fieldName, info := range fieldInfos {
|
|
// Check if channel already exists
|
|
if _, exists := g.channels[fieldName]; !exists {
|
|
// Add channel
|
|
g.channels[fieldName] = info.Channel
|
|
}
|
|
|
|
// Set reducer if specified in annotation
|
|
if info.Annotation != nil && info.Annotation.Reducer != nil {
|
|
g.reducers[fieldName] = info.Annotation.Reducer
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Compile validates the graph structure and produces an executable CompiledGraph.
|
|
// Validation includes reachability checks (all nodes reachable from the entry point),
|
|
// state schema validation, and channel configuration from struct annotations.
|
|
//
|
|
// opts configure runtime behavior:
|
|
// - WithCheckpointer: enable persistence for interrupt/resume
|
|
// - WithInterrupts: set human-in-the-loop breakpoints
|
|
// - WithRecursionLimit: cap Pregel iterations (default 25)
|
|
// - WithDebug: enable verbose execution logging
|
|
//
|
|
// Example:
|
|
//
|
|
// cg, err := sg.Compile(
|
|
// graph.WithCheckpointer(mySaver),
|
|
// graph.WithInterrupts("human_review"),
|
|
// )
|
|
func (g *StateGraph) Compile(opts ...CompileOption) (*CompiledGraph, error) {
|
|
if err := g.Validate(); err != nil {
|
|
return nil, fmt.Errorf("graph validation failed: %w", err)
|
|
}
|
|
|
|
// Configure channels and reducers from schema annotations
|
|
if err := g.configureChannelsFromSchema(); err != nil {
|
|
return nil, fmt.Errorf("failed to configure channels from schema: %w", err)
|
|
}
|
|
|
|
cg := &CompiledGraph{
|
|
graph: g,
|
|
checkpointer: nil,
|
|
interrupts: make(map[string]bool),
|
|
interruptsAfter: make(map[string]bool),
|
|
recursionLimit: constants.DefaultRecursionLimit,
|
|
debug: false,
|
|
nodeTriggerMode: types.NodeTriggerAnyPredecessor,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(cg)
|
|
}
|
|
|
|
// Propagate node trigger mode to the graph for the engine to access.
|
|
g.NodeTriggerMode = cg.nodeTriggerMode
|
|
|
|
return cg, nil
|
|
}
|
|
|
|
// CompileOption configures CompiledGraph behavior at compile time.
|
|
type CompileOption func(*CompiledGraph)
|
|
|
|
// WithCheckpointer enables checkpoint-based persistence for interrupt/resume.
|
|
// The checkpointer is called at each Pregel step to save execution state.
|
|
// Built-in implementations: MemorySaver, SqliteSaver, PostgresSaver.
|
|
func WithCheckpointer(checkpointer Checkpointer) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
cg.checkpointer = checkpointer
|
|
}
|
|
}
|
|
|
|
// WithInterrupts marks one or more nodes as interrupt points (human-in-the-loop
|
|
// breakpoints). Execution pauses before these nodes and can be resumed later
|
|
// via the checkpointer. Use "*" to interrupt before every node.
|
|
func WithInterrupts(nodes ...string) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
for _, node := range nodes {
|
|
cg.interrupts[node] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithInterruptsAfter marks one or more nodes as interrupt-after points.
|
|
// Execution pauses AFTER the named node completes, saving a checkpoint with
|
|
// the node's output, then returns GraphInterrupt for resume.
|
|
// Use "*" to interrupt after every node.
|
|
func WithInterruptsAfter(nodes ...string) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
for _, node := range nodes {
|
|
cg.interruptsAfter[node] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithRecursionLimit sets the maximum number of Pregel iterations before the
|
|
// graph aborts with GraphRecursionError. The default is 25. Increase for deeply
|
|
// nested or iterative graphs, decrease to catch runaway loops early.
|
|
func WithRecursionLimit(limit int) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
cg.recursionLimit = limit
|
|
}
|
|
}
|
|
|
|
// WithDebug enables verbose execution logging for debugging node execution
|
|
// order, channel state transitions, and task scheduling.
|
|
func WithDebug(debug bool) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
cg.debug = debug
|
|
}
|
|
}
|
|
|
|
// WithNodeTriggerMode sets the node trigger mode for graph execution.
|
|
// - NodeTriggerAnyPredecessor (default): Pregel/BSP mode, triggers when any
|
|
// predecessor completes. Supports cycles and loops.
|
|
// - NodeTriggerAllPredecessor: DAG mode, triggers only when ALL predecessors have
|
|
// completed. Required for fan-in/convergence patterns. Does not support cycles.
|
|
func WithNodeTriggerMode(mode types.NodeTriggerMode) CompileOption {
|
|
return func(cg *CompiledGraph) {
|
|
cg.nodeTriggerMode = mode
|
|
}
|
|
}
|
|
|
|
// Checkpointer is the interface for checkpoint persistence.
|
|
// It is a type alias for checkpoint.BaseCheckpointer.
|
|
type Checkpointer = checkpoint.BaseCheckpointer
|
|
|
|
// ---- Pregel runner bridge ----
|
|
//
|
|
// PregelRunFunc is the pluggable execution function for CompiledGraph.
|
|
// It allows the root harness package to inject a pregel.Engine-based runner
|
|
// without creating an import cycle (graph → pregel → graph).
|
|
//
|
|
// The default value (nil) falls back to the inline Pregel loop.
|
|
// Set it via SetPregelRunFunc, typically from an init() in the root harness package.
|
|
var PregelRunFunc func(ctx context.Context, cg *CompiledGraph, input interface{}, config *types.RunnableConfig, streamMode types.StreamMode) (interface{}, error)
|
|
|
|
// SetPregelRunFunc replaces the default Pregel execution function.
|
|
// Called from harness.go's init() to inject a pregel.Engine-based runner.
|
|
// External consumers should compile graphs via sg.Compile() and call Invoke/Stream;
|
|
// they do not need to call SetPregelRunFunc directly.
|
|
func SetPregelRunFunc(fn func(ctx context.Context, cg *CompiledGraph, input interface{}, config *types.RunnableConfig, streamMode types.StreamMode) (interface{}, error)) {
|
|
PregelRunFunc = fn
|
|
}
|
|
|
|
// CompiledGraph is a compiled, executable graph produced by StateGraph.Compile().
|
|
//
|
|
// It provides two execution paths:
|
|
// - Invoke: synchronous, returns final state
|
|
// - Stream: asynchronous, returns channels for streaming events
|
|
//
|
|
// Execution delegates to PregelRunFunc (production) or falls back to an inline
|
|
// Pregel loop (backward compatibility).
|
|
//
|
|
// Example:
|
|
//
|
|
// cg, err := sg.Compile(graph.WithCheckpointer(memSaver))
|
|
// result, err := cg.Invoke(ctx, MyState{Messages: []string{"hello"}})
|
|
type CompiledGraph struct {
|
|
graph *StateGraph
|
|
checkpointer Checkpointer
|
|
interrupts map[string]bool // nodes to interrupt BEFORE execution
|
|
interruptsAfter map[string]bool // nodes to interrupt AFTER execution
|
|
recursionLimit int
|
|
debug bool
|
|
nodeTriggerMode types.NodeTriggerMode
|
|
}
|
|
|
|
// Invoke executes the graph synchronously. It applies input to the state
|
|
// channels, runs the Pregel loop, and returns the final state after all nodes
|
|
// complete or an interrupt/error occurs.
|
|
//
|
|
// config is optional; when nil, a default RunnableConfig is used. For resumable
|
|
// execution, pass a config with ThreadID and a checkpointer configured during
|
|
// Compile().
|
|
func (cg *CompiledGraph) Invoke(ctx context.Context, input interface{}, config ...*types.RunnableConfig) (interface{}, error) {
|
|
rc := &types.RunnableConfig{}
|
|
if len(config) > 0 && config[0] != nil {
|
|
rc = config[0]
|
|
}
|
|
|
|
result, err := cg.run(ctx, input, rc, types.StreamModeValues)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Stream executes the graph and returns channels for receiving streaming events.
|
|
// outputCh yields stream events (checkpoint snapshots, task start/end, value updates,
|
|
// or the final state depending on streamMode). errCh receives a single error or nil
|
|
// when execution completes.
|
|
//
|
|
// streamMode controls which events are emitted:
|
|
// - StreamModeValues: final state only
|
|
// - StreamModeUpdates: per-node state updates
|
|
// - StreamModeTasks: task lifecycle events
|
|
// - StreamModeCheckpoints: checkpoint snapshots
|
|
// - StreamModeDebug: all event types
|
|
func (cg *CompiledGraph) Stream(ctx context.Context, input interface{}, mode types.StreamMode, config ...*types.RunnableConfig) (<-chan interface{}, <-chan error) {
|
|
outputCh := make(chan interface{}, 1) // Buffer to reduce blocking
|
|
errCh := make(chan error, 1)
|
|
|
|
rc := &types.RunnableConfig{}
|
|
if len(config) > 0 && config[0] != nil {
|
|
rc = config[0]
|
|
}
|
|
|
|
go func() {
|
|
defer close(outputCh)
|
|
defer close(errCh)
|
|
|
|
result, err := cg.run(ctx, input, rc, mode)
|
|
if err != nil {
|
|
select {
|
|
case errCh <- err:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
|
|
select {
|
|
case outputCh <- result:
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
return outputCh, errCh
|
|
}
|
|
|
|
// run delegates to the configured Pregel runner, or falls back to the inline
|
|
// Pregel loop when no external runner is set.
|
|
func (cg *CompiledGraph) run(ctx context.Context, input interface{}, config *types.RunnableConfig, streamMode types.StreamMode) (interface{}, error) {
|
|
if PregelRunFunc != nil {
|
|
return PregelRunFunc(ctx, cg, input, config, streamMode)
|
|
}
|
|
return cg.inlineRun(ctx, input, config)
|
|
}
|
|
|
|
// inlineRun is the default inline Pregel loop kept as a fallback.
|
|
// It is only used when no PregelRunFunc has been set via SetPregelRunFunc.
|
|
// For production use, the pregel.Engine (injected via harness.init()) provides
|
|
// full async pipeline, streaming, and checkpoint support.
|
|
// TODO: Consider moving this to a separate file or removing entirely once
|
|
// all consumers use the pregel engine path.
|
|
func (cg *CompiledGraph) inlineRun(ctx context.Context, input interface{}, config *types.RunnableConfig) (interface{}, error) {
|
|
g := cg.graph
|
|
channelRegistry := channels.NewRegistry()
|
|
for name, ch := range g.GetChannels() {
|
|
channelRegistry.Register(name, ch.Copy())
|
|
}
|
|
|
|
if input != nil {
|
|
if err := inlineApplyInput(channelRegistry, input); err != nil {
|
|
return nil, fmt.Errorf("failed to apply input: %w", err)
|
|
}
|
|
}
|
|
|
|
if cg.checkpointer != nil {
|
|
threadID := getThreadID(config)
|
|
cp, err := cg.checkpointer.Get(ctx, map[string]interface{}{
|
|
constants.ConfigKeyThreadID: threadID,
|
|
})
|
|
if err == nil && cp != nil {
|
|
if err := channelRegistry.RestoreFromCheckpoint(cp); err != nil {
|
|
return nil, fmt.Errorf("failed to restore from checkpoint: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
step := 0
|
|
completedTasks := make(map[string]bool)
|
|
lastCompletedNode := ""
|
|
lastState := input
|
|
|
|
for {
|
|
if step >= cg.recursionLimit {
|
|
return nil, &errors.GraphRecursionError{Limit: cg.recursionLimit}
|
|
}
|
|
|
|
tasks, err := inlineGetNextTasks(ctx, channelRegistry, completedTasks, lastCompletedNode, lastState, g)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get next tasks: %w", err)
|
|
}
|
|
if len(tasks) == 0 {
|
|
break
|
|
}
|
|
|
|
interrupted := inlineShouldInterrupt(tasks, cg.interrupts)
|
|
if interrupted {
|
|
if cg.checkpointer != nil {
|
|
cp := channelRegistry.CreateCheckpoint()
|
|
_ = cg.checkpointer.Put(ctx, map[string]interface{}{
|
|
constants.ConfigKeyThreadID: getThreadID(config),
|
|
}, cp)
|
|
}
|
|
return nil, &errors.GraphInterrupt{}
|
|
}
|
|
|
|
results, err := inlineExecuteTasks(ctx, tasks, g)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute tasks: %w", err)
|
|
}
|
|
|
|
for _, result := range results {
|
|
if result.err != nil {
|
|
// Pass GraphInterrupt through unwrapped so the caller
|
|
// (e.g. runLoop in loop.go) can detect it via direct
|
|
// type assertion. Wrapping it would prevent detection.
|
|
if errors.IsGraphInterrupt(result.err) {
|
|
return nil, result.err
|
|
}
|
|
return nil, fmt.Errorf("node %s failed: %w", result.nodeName, result.err)
|
|
}
|
|
completedTasks[result.nodeName] = true
|
|
lastCompletedNode = result.nodeName
|
|
lastState = inlineMergeStates(lastState, result.output)
|
|
}
|
|
|
|
if err := inlineApplyWrites(channelRegistry, results); err != nil {
|
|
return nil, fmt.Errorf("failed to apply writes: %w", err)
|
|
}
|
|
|
|
if cg.checkpointer != nil {
|
|
cp := channelRegistry.CreateCheckpoint()
|
|
_ = cg.checkpointer.Put(ctx, map[string]interface{}{
|
|
constants.ConfigKeyThreadID: getThreadID(config),
|
|
"step": step,
|
|
}, cp)
|
|
}
|
|
|
|
// Check for after-node interrupts. The checkpoint above already
|
|
// captures this step's output, so a resume starts with the node's
|
|
// data in place.
|
|
if inlineShouldInterruptAfter(results, cg.interruptsAfter) {
|
|
return nil, &errors.GraphInterrupt{}
|
|
}
|
|
|
|
step++
|
|
}
|
|
|
|
finalState, err := inlineBuildOutput(channelRegistry, lastState)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build output: %w", err)
|
|
}
|
|
return finalState, nil
|
|
}
|
|
|
|
// GetGraph returns the underlying StateGraph for read-only inspection.
|
|
func (cg *CompiledGraph) GetGraph() *StateGraph {
|
|
return cg.graph
|
|
}
|
|
|
|
// GetCheckpointer returns the configured checkpointer, or nil if none was set.
|
|
func (cg *CompiledGraph) GetCheckpointer() Checkpointer {
|
|
return cg.checkpointer
|
|
}
|
|
|
|
// GetInterrupts returns the set of node names that are configured to interrupt
|
|
// execution (human-in-the-loop breakpoints) BEFORE node execution.
|
|
func (cg *CompiledGraph) GetInterrupts() map[string]bool {
|
|
return cg.interrupts
|
|
}
|
|
|
|
// GetInterruptsAfter returns the set of node names that are configured to
|
|
// interrupt execution AFTER node execution.
|
|
func (cg *CompiledGraph) GetInterruptsAfter() map[string]bool {
|
|
return cg.interruptsAfter
|
|
}
|
|
|
|
// GetRecursionLimit returns the maximum number of Pregel steps before the
|
|
// graph aborts with a GraphRecursionError.
|
|
func (cg *CompiledGraph) GetRecursionLimit() int {
|
|
return cg.recursionLimit
|
|
}
|
|
|
|
// IsDebug reports whether debug mode is enabled for detailed execution logging.
|
|
func (cg *CompiledGraph) IsDebug() bool {
|
|
return cg.debug
|
|
}
|
|
|
|
// ---- Inline Pregel execution helpers (fallback when no external runner is set) ----
|
|
|
|
type inlineTask struct {
|
|
id string
|
|
nodeName string
|
|
input interface{}
|
|
}
|
|
|
|
type inlineTaskResult struct {
|
|
taskID string
|
|
nodeName string
|
|
output interface{}
|
|
err error
|
|
}
|
|
|
|
func getThreadID(config *types.RunnableConfig) string {
|
|
if config != nil && config.Configurable != nil {
|
|
if tid, ok := config.Configurable[constants.ConfigKeyThreadID].(string); ok {
|
|
return tid
|
|
}
|
|
}
|
|
return uuid.New().String()
|
|
}
|
|
|
|
func inlineApplyInput(registry *channels.Registry, input interface{}) error {
|
|
inputMap, err := inlineToMap(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writes := make(map[string][]interface{})
|
|
for key, value := range inputMap {
|
|
if _, ok := registry.Get(key); ok {
|
|
writes[key] = []interface{}{value}
|
|
}
|
|
}
|
|
if len(writes) > 0 {
|
|
return registry.UpdateChannels(writes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inlineGetNextTasks(ctx context.Context, registry *channels.Registry, completedTasks map[string]bool, lastCompletedNode string, currentState interface{}, g *StateGraph) ([]*inlineTask, error) {
|
|
tasks := make([]*inlineTask, 0)
|
|
if len(completedTasks) == 0 && g.entryPoint != "" {
|
|
node, ok := g.GetNode(g.entryPoint)
|
|
if !ok {
|
|
return nil, &errors.NodeNotFoundError{NodeName: g.entryPoint}
|
|
}
|
|
tasks = append(tasks, &inlineTask{id: uuid.New().String(), nodeName: node.Name, input: currentState})
|
|
return tasks, nil
|
|
}
|
|
if lastCompletedNode != "" {
|
|
nextNodes := make(map[string]bool)
|
|
hasConditional := false
|
|
for _, condEdge := range g.conditionalEdges {
|
|
if condEdge.From == lastCompletedNode {
|
|
hasConditional = true
|
|
conditionResult, err := condEdge.Condition(ctx, currentState)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("conditional edge condition from '%s' failed: %w", lastCompletedNode, err)
|
|
}
|
|
conditionKey := fmt.Sprintf("%v", conditionResult)
|
|
targetNode, ok := condEdge.Mapping[conditionKey]
|
|
if !ok {
|
|
return nil, fmt.Errorf("conditional edge from '%s': condition key '%v' not mapped", lastCompletedNode, conditionResult)
|
|
}
|
|
if targetNode == constants.End {
|
|
return tasks, nil
|
|
}
|
|
nextNodes[targetNode] = true
|
|
}
|
|
}
|
|
if !hasConditional && len(nextNodes) == 0 {
|
|
for _, edge := range g.edges {
|
|
if edge.From == lastCompletedNode {
|
|
if edge.To == constants.End {
|
|
return tasks, nil
|
|
}
|
|
// BSP loop edges: always schedule, even if previously completed.
|
|
// completedTasks only prevents re-scheduling the SAME node,
|
|
// not nodes reached via outgoing edges (support loops).
|
|
nextNodes[edge.To] = true
|
|
}
|
|
}
|
|
}
|
|
// Resume fallback: when lastCompletedNode has no outgoing edges
|
|
// but currentState contains _next (persisted from Switch/Categorize),
|
|
// route directly from _next. This handles checkpoint resume where
|
|
// the conditional edge is on a different node.
|
|
if len(nextNodes) == 0 {
|
|
if st, ok := currentState.(map[string]any); ok {
|
|
if raw, has := st["_next"]; has && raw != nil {
|
|
switch tv := raw.(type) {
|
|
case string:
|
|
if _, exists := g.GetNode(tv); exists {
|
|
nextNodes[tv] = true
|
|
}
|
|
case []any:
|
|
for _, item := range tv {
|
|
if str, ok := item.(string); ok {
|
|
if _, exists := g.GetNode(str); exists {
|
|
nextNodes[str] = true
|
|
}
|
|
}
|
|
}
|
|
case []string:
|
|
for _, str := range tv {
|
|
if _, exists := g.GetNode(str); exists {
|
|
nextNodes[str] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for nodeName := range nextNodes {
|
|
node, ok := g.GetNode(nodeName)
|
|
if ok {
|
|
tasks = append(tasks, &inlineTask{id: uuid.New().String(), nodeName: node.Name, input: currentState})
|
|
}
|
|
}
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
func inlineShouldInterrupt(tasks []*inlineTask, interrupts map[string]bool) bool {
|
|
if len(interrupts) == 0 {
|
|
return false
|
|
}
|
|
interruptAll := interrupts[types.All]
|
|
for _, t := range tasks {
|
|
if interruptAll || interrupts[t.nodeName] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// inlineShouldInterruptAfter checks if any SUCCESSFULLY completed task's node
|
|
// name is in interruptsAfter. This is called AFTER execution and checkpoint
|
|
// save, so the checkpoint captures the node's output.
|
|
func inlineShouldInterruptAfter(results []*inlineTaskResult, interruptsAfter map[string]bool) bool {
|
|
if len(interruptsAfter) == 0 {
|
|
return false
|
|
}
|
|
interruptAll := interruptsAfter[types.All]
|
|
for _, r := range results {
|
|
if r.err != nil {
|
|
continue
|
|
}
|
|
if interruptAll || interruptsAfter[r.nodeName] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func inlineExecuteTasks(ctx context.Context, tasks []*inlineTask, g *StateGraph) ([]*inlineTaskResult, error) {
|
|
results := make([]*inlineTaskResult, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
node, ok := g.GetNode(t.nodeName)
|
|
if !ok {
|
|
return nil, &errors.NodeNotFoundError{NodeName: t.nodeName}
|
|
}
|
|
var output interface{}
|
|
var err error
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("node %s panic: %v", t.nodeName, r)
|
|
}
|
|
}()
|
|
output, err = node.Function(ctx, t.input)
|
|
}()
|
|
results = append(results, &inlineTaskResult{taskID: t.id, nodeName: t.nodeName, output: output, err: err})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func inlineApplyWrites(registry *channels.Registry, results []*inlineTaskResult) error {
|
|
writes := make(map[string][]interface{})
|
|
for _, result := range results {
|
|
if result.err != nil {
|
|
continue
|
|
}
|
|
outputMap, err := inlineToMap(result.output)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert output to map: %w", err)
|
|
}
|
|
for key, value := range outputMap {
|
|
if _, ok := registry.Get(key); ok {
|
|
writes[key] = append(writes[key], value)
|
|
}
|
|
}
|
|
}
|
|
if len(writes) > 0 {
|
|
return registry.UpdateChannels(writes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inlineBuildOutput(registry *channels.Registry, lastState interface{}) (interface{}, error) {
|
|
values, err := registry.GetValues()
|
|
if err != nil {
|
|
return lastState, nil
|
|
}
|
|
if len(values) > 0 {
|
|
return values, nil
|
|
}
|
|
return lastState, nil
|
|
}
|
|
|
|
func inlineMergeStates(existing, next any) any {
|
|
if existing == nil {
|
|
return next
|
|
}
|
|
if next == nil {
|
|
return existing
|
|
}
|
|
existingMap, ok1 := existing.(map[string]any)
|
|
nextMap, ok2 := next.(map[string]any)
|
|
if ok1 && ok2 {
|
|
result := make(map[string]any)
|
|
for key, val := range existingMap {
|
|
result[key] = val
|
|
}
|
|
for key, val := range nextMap {
|
|
result[key] = val
|
|
}
|
|
return result
|
|
}
|
|
return next
|
|
}
|
|
|
|
func inlineToMap(val any) (map[string]any, error) {
|
|
if val == nil {
|
|
return nil, fmt.Errorf("nil value")
|
|
}
|
|
if m, ok := val.(map[string]any); ok {
|
|
return m, nil
|
|
}
|
|
rv := reflect.ValueOf(val)
|
|
if rv.Kind() == reflect.Ptr {
|
|
rv = rv.Elem()
|
|
}
|
|
if rv.Kind() != reflect.Struct && rv.Kind() != reflect.Map {
|
|
return map[string]any{"__root__": val}, nil
|
|
}
|
|
result := make(map[string]any)
|
|
if rv.Kind() == reflect.Map {
|
|
for _, key := range rv.MapKeys() {
|
|
result[fmt.Sprintf("%v", key.Interface())] = rv.MapIndex(key).Interface()
|
|
}
|
|
return result, nil
|
|
}
|
|
rt := rv.Type()
|
|
for i := 0; i < rv.NumField(); i++ {
|
|
field := rt.Field(i)
|
|
if field.PkgPath != "" {
|
|
continue
|
|
}
|
|
result[field.Name] = rv.Field(i).Interface()
|
|
}
|
|
return result, nil
|
|
}
|