Skip to main content

oneai_workflow/
state_graph.rs

1//! StateGraph — directed graph that supports cyclic edges for agent workflows.
2//!
3//! Unlike the existing `WorkflowDag` which is a DAG (no cycles),
4//! `StateGraph` supports cyclic edges, making it suitable for modeling
5//! ReAct loops and other iterative agent patterns as explicit graph structures.
6//!
7//! This is inspired by LangGraph's core innovation: cyclic graphs enable
8//! ReAct loops (Think → Act → Observe → Think) as explicit cycles
9//! rather than implicit while loops. This makes state visible, inspectable,
10//! and interruptable.
11//!
12//! Key differences from WorkflowDag:
13//! - Supports conditional edges (edges with conditions that determine routing)
14//! - Supports cyclic edges (edges that form loops)
15//! - Supports interrupt points (nodes where execution can be paused)
16//! - State is explicitly passed through nodes rather than accumulated
17//!
18//! The WorkflowDag is retained for pure declaration-style DAG workflows
19//! (parallel step orchestration). StateGraph is used for agent flows
20//! that need iteration and dynamic routing.
21
22use std::collections::{BTreeMap, HashMap};
23
24use serde::{Deserialize, Serialize};
25
26// ─── NodeAction ─────────────────────────────────────────────────────────────
27
28/// The action performed by a graph node.
29///
30/// Each node can perform one of several action types:
31/// - LLM inference (the core reasoning step)
32/// - Tool execution (calling a specific tool)
33/// - Sub-agent delegation (spawning a specialized sub-agent)
34/// - Human approval (a checkpoint requiring human intervention)
35/// - Condition check (a routing node that evaluates a condition)
36/// - Paradigm switch (changing the active paradigm for subsequent nodes)
37///
38/// When a `GraphActionExecutor` (from `oneai-agent`) is configured,
39/// the LlmInfer and ToolCall nodes delegate to the AgentLoop's full
40/// inference and execution pipeline (context assembly, tool definitions,
41/// hooks, permission, domain pack). This is the P2-2 "闭环" mechanism.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[non_exhaustive]
44pub enum NodeAction {
45    /// LLM inference node — sends conversation to the model and gets a response.
46    ///
47    /// When `include_tool_definitions` is true, the GraphActionExecutor will
48    /// build tool definitions for this node (filtered by `tool_filter_override`
49    /// or the active paradigm's tool set). This is critical for ReAct loops —
50    /// the model needs to see available tools to decide whether to call them.
51    ///
52    /// When `include_tool_definitions` is false, the node sends a pure inference
53    /// request without tool definitions (for final answer nodes, condition checks, etc).
54    LlmInfer {
55        /// System prompt override for this node (if any).
56        system_prompt_override: Option<String>,
57        /// Whether to use streaming inference.
58        #[serde(default)]
59        use_streaming: bool,
60        /// Whether to include tool definitions in the inference request.
61        /// When true, the GraphActionExecutor builds tool definitions based on
62        /// `tool_filter_override` or the active paradigm config.
63        /// When false (default), the LLM receives a pure text prompt without tools.
64        #[serde(default)]
65        include_tool_definitions: bool,
66        /// Override the tool filter for this node (if any).
67        /// When Some, only these tools are included in the inference request.
68        /// When None, the active paradigm's tool_filter is used (or all tools
69        /// if no paradigm is active).
70        #[serde(default)]
71        tool_filter_override: Option<Vec<String>>,
72        /// Token budget for extended thinking/reasoning (Anthropic budget_tokens).
73        /// None = thinking disabled; Some(N) = enable thinking with N token budget.
74        #[serde(default)]
75        thinking_budget: Option<u32>,
76        /// Temperature for sampling (0.0 = deterministic, 1.0 = creative).
77        #[serde(default)]
78        temperature: Option<f32>,
79        /// Maximum tokens to generate.
80        #[serde(default)]
81        max_tokens: Option<u32>,
82    },
83
84    /// Tool execution node — calls a specific tool with arguments.
85    ToolCall {
86        /// The tool name to call.
87        tool_name: String,
88        /// Template for constructing tool arguments from state.
89        /// Uses {{variable}} syntax for state variable interpolation.
90        args_template: Option<String>,
91    },
92
93    /// Sub-agent delegation node — spawns a specialized sub-agent.
94    Delegate {
95        /// The sub-agent kind to spawn.
96        agent_kind: String,
97        /// Template for constructing the task description from state.
98        task_template: String,
99    },
100
101    /// Human approval node — pauses execution for human review.
102    /// This is an interrupt point where the user can inspect state
103    /// and decide to continue, modify, or abort.
104    HumanApproval {
105        /// The description of what requires approval.
106        description: String,
107    },
108
109    /// Condition check node — evaluates a condition and routes to different edges.
110    /// This is the mechanism for dynamic routing in the graph.
111    ConditionCheck {
112        /// The condition expression to evaluate.
113        /// Examples: "has_tool_calls", "is_final_answer", "error_occurred"
114        condition: String,
115    },
116
117    /// Paradigm switch node — changes the active paradigm for subsequent nodes.
118    ///
119    /// When executed, this node updates `GraphState.active_paradigm` and
120    /// clears `GraphState.parsed_decision`. Subsequent LlmInfer nodes
121    /// (with `include_tool_definitions = true`) will use the new paradigm's
122    /// tool set and system prompt. This enables multi-paradigm workflows
123    /// as explicit graph paths (e.g., Plan → ReAct → Reflect).
124    ///
125    /// The paradigm string maps to ParadigmKind in `oneai-agent`:
126    /// "plan", "react", "reflect", "explore".
127    SwitchParadigm {
128        /// The target paradigm to switch to.
129        paradigm: String,
130    },
131}
132
133// ─── EdgeCondition ──────────────────────────────────────────────────────────
134
135/// A condition that determines whether an edge is followed.
136///
137/// Edges with conditions enable dynamic routing in the graph:
138/// - After an LLM inference node, route to tool execution if tool calls present
139/// - After a tool execution node, route back to LLM inference (ReAct loop)
140/// - After an LLM inference node, route to end if no tool calls (final answer)
141///
142/// P2-2 improvement: HasToolCalls, IsFinalAnswer, and RequestsDelegation
143/// now evaluate against `GraphState.parsed_decision` (a structured `GraphDecision`)
144/// rather than unreliable string pattern matching. This makes edge routing
145/// consistent with the AgentLoop's decision parsing.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[non_exhaustive]
148pub enum EdgeCondition {
149    /// Route if the model output contains tool calls.
150    /// Now evaluates `state.parsed_decision == GraphDecision::ToolCalls`.
151    HasToolCalls,
152
153    /// Route if the model output is a final answer (no tool calls).
154    /// Now evaluates `state.parsed_decision == GraphDecision::DirectAnswer`.
155    IsFinalAnswer,
156
157    /// Route if the model output requests delegation.
158    /// Now evaluates `state.parsed_decision == GraphDecision::Delegate`.
159    RequestsDelegation,
160
161    /// Route if an error occurred in the previous node.
162    ErrorOccurred,
163
164    /// Route if a specific state variable has a certain value.
165    StateEquals { variable: String, value: String },
166
167    /// Always route (no condition — unconditional edge).
168    Always,
169
170    /// Custom condition — evaluated by a user-provided function.
171    Custom { name: String, description: String },
172
173    /// Route if the active paradigm matches the specified paradigm.
174    /// Evaluates `state.active_paradigm == paradigm`.
175    ParadigmEquals {
176        /// The paradigm to match (e.g., "plan", "react", "reflect", "explore").
177        paradigm: String,
178    },
179
180    /// Route if the iteration count exceeds the specified threshold.
181    /// Useful for adding safety caps to iterative loops.
182    IterationExceeds {
183        /// The iteration threshold — route is followed when iterations > count.
184        count: usize,
185    },
186}
187
188impl EdgeCondition {
189    /// Check if this condition is unconditional (always routes).
190    pub fn is_unconditional(&self) -> bool {
191        matches!(self, Self::Always)
192    }
193}
194
195// ─── GraphNode ──────────────────────────────────────────────────────────────
196
197/// A node in the StateGraph.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct GraphNode {
200    /// Unique node identifier.
201    pub id: String,
202
203    /// The action this node performs.
204    pub action: NodeAction,
205
206    /// Whether this node is an interrupt point (execution can pause here).
207    /// Interrupt points allow human observation and intervention.
208    #[serde(default)]
209    pub interrupt: bool,
210
211    /// Metadata for this node.
212    #[serde(default)]
213    pub metadata: HashMap<String, String>,
214}
215
216// ─── GraphEdge ──────────────────────────────────────────────────────────────
217
218/// An edge in the StateGraph — connects two nodes with optional routing condition.
219///
220/// Edges can be:
221/// - Unconditional: always followed after the source node completes
222/// - Conditional: followed only if the condition evaluates to true
223/// - Cyclic: the target node can be an ancestor (forming a loop)
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct GraphEdge {
226    /// The source node ID.
227    pub from: String,
228
229    /// The target node ID.
230    pub to: String,
231
232    /// The condition for following this edge (None = unconditional).
233    #[serde(default)]
234    pub condition: Option<EdgeCondition>,
235
236    /// Edge metadata.
237    #[serde(default)]
238    pub metadata: HashMap<String, String>,
239}
240
241// ─── StateGraph ─────────────────────────────────────────────────────────────
242
243/// A directed graph that supports cyclic edges for agent workflows.
244///
245/// Unlike DAGs, StateGraphs can contain cycles, enabling ReAct loops
246/// and other iterative patterns to be modeled as explicit graph structures.
247/// This makes the state machine visible, inspectable, and interruptable.
248///
249/// Example: ReAct as a StateGraph (diagram):
250///
251/// think_node (LLMInfer) → [HasToolCalls] → tool_node (ToolCall) → think_node (cycle!)
252/// think_node (LLMInfer) → [IsFinalAnswer] → end_node
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct StateGraph {
255    /// The graph name/identifier.
256    pub name: String,
257
258    /// All nodes in the graph, keyed by node ID.
259    ///
260    /// `BTreeMap` (not `HashMap`) makes iteration order deterministic across
261    /// runs — important for reproducible rendering, snapshot tests, and
262    /// stable edge-fan-out ordering.
263    pub nodes: BTreeMap<String, GraphNode>,
264
265    /// All edges in the graph, keyed by source node ID.
266    /// Each source node can have multiple outgoing edges (with conditions).
267    /// `BTreeMap` for deterministic source-node iteration order.
268    pub edges: BTreeMap<String, Vec<GraphEdge>>,
269
270    /// The entry point node ID (where execution starts).
271    pub entry_point: String,
272
273    /// Terminal node IDs (where execution ends).
274    #[serde(default)]
275    pub terminal_nodes: Vec<String>,
276}
277
278impl StateGraph {
279    /// Create a new StateGraph with a name and entry point.
280    pub fn new(name: impl Into<String>, entry_point: impl Into<String>) -> Self {
281        Self {
282            name: name.into(),
283            nodes: BTreeMap::new(),
284            edges: BTreeMap::new(),
285            entry_point: entry_point.into(),
286            terminal_nodes: Vec::new(),
287        }
288    }
289
290    /// Add a node to the graph.
291    pub fn add_node(&mut self, node: GraphNode) {
292        self.nodes.insert(node.id.clone(), node);
293    }
294
295    /// Add an edge to the graph.
296    pub fn add_edge(&mut self, edge: GraphEdge) {
297        self.edges.entry(edge.from.clone()).or_default().push(edge);
298    }
299
300    /// Add a terminal node (execution ends here).
301    pub fn add_terminal(&mut self, node_id: impl Into<String>) {
302        self.terminal_nodes.push(node_id.into());
303    }
304
305    /// Get a node by ID.
306    pub fn get_node(&self, id: &str) -> Option<&GraphNode> {
307        self.nodes.get(id)
308    }
309
310    /// Get outgoing edges from a node.
311    pub fn get_edges_from(&self, node_id: &str) -> Vec<&GraphEdge> {
312        self.edges
313            .get(node_id)
314            .map(|edges| edges.iter().collect())
315            .unwrap_or_default()
316    }
317
318    /// Get the number of nodes.
319    pub fn node_count(&self) -> usize {
320        self.nodes.len()
321    }
322
323    /// Get the number of edges.
324    pub fn edge_count(&self) -> usize {
325        self.edges.values().map(|v| v.len()).sum()
326    }
327
328    /// Check if the graph contains any cycles.
329    ///
330    /// Unlike DAGs, cycles are ALLOWED in StateGraphs (they're the mechanism
331    /// for ReAct loops). This method is for diagnostics, not validation.
332    pub fn has_cycles(&self) -> bool {
333        // Use DFS to detect back edges
334        let mut visited = std::collections::HashSet::new();
335        let mut recursion_stack = std::collections::HashSet::new();
336
337        fn dfs(
338            node_id: &str,
339            graph: &StateGraph,
340            visited: &mut std::collections::HashSet<String>,
341            recursion_stack: &mut std::collections::HashSet<String>,
342        ) -> bool {
343            visited.insert(node_id.to_string());
344            recursion_stack.insert(node_id.to_string());
345
346            for edge in graph.get_edges_from(node_id) {
347                if !visited.contains(&edge.to) {
348                    if dfs(&edge.to, graph, visited, recursion_stack) {
349                        return true;
350                    }
351                } else if recursion_stack.contains(&edge.to) {
352                    return true; // Cycle detected
353                }
354            }
355
356            recursion_stack.remove(node_id);
357            false
358        }
359
360        for node_id in self.nodes.keys() {
361            if !visited.contains(node_id) && dfs(node_id, self, &mut visited, &mut recursion_stack)
362            {
363                return true;
364            }
365        }
366        false
367    }
368}
369
370// ─── GraphState ─────────────────────────────────────────────────────────────
371
372/// The state that flows through the StateGraph during execution.
373///
374/// State is passed from node to node via the edges. Each node can
375/// read from and write to this state. The state is serializable
376/// for checkpoint persistence.
377///
378/// P2-2 extension: added fields for AgentLoop integration —
379/// `parsed_decision` enables edge routing based on structured decisions
380/// (not unreliable string pattern matching), `active_paradigm` tracks
381/// the current paradigm for tool filtering, and `iteration_count`
382/// enables IterationExceeds edge conditions for loop safety.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct GraphState {
385    /// The current conversation.
386    pub conversation: oneai_core::Conversation,
387
388    /// State variables (key-value pairs accessible by nodes).
389    pub variables: HashMap<String, String>,
390
391    /// The last action result (output from the most recently executed node).
392    pub last_result: Option<String>,
393
394    /// Error message (if the last node failed).
395    pub last_error: Option<String>,
396
397    /// Whether the graph execution should terminate.
398    pub should_terminate: bool,
399
400    /// Metadata.
401    #[serde(default)]
402    pub metadata: HashMap<String, String>,
403
404    // ─── P2-2: AgentLoop integration fields ──────────────────────────────────
405    /// The parsed decision from the most recent LLM inference.
406    ///
407    /// After each LlmInfer node, the `GraphActionExecutor` parses the LLM
408    /// response into a `GraphDecision` and stores it here. Edge conditions
409    /// (HasToolCalls, IsFinalAnswer, RequestsDelegation) evaluate against
410    /// this field instead of raw string content, making routing reliable
411    /// and consistent with the AgentLoop's decision parsing.
412    ///
413    /// Cleared when a SwitchParadigm node is executed (new inference needed).
414    #[serde(default)]
415    pub parsed_decision: Option<oneai_core::GraphDecision>,
416
417    /// The active paradigm for tool filtering and system prompts.
418    ///
419    /// When set, subsequent LlmInfer nodes (with `include_tool_definitions = true`)
420    /// use the paradigm's tool_filter to build tool definitions. Paradigm-specific
421    /// system prompts are also injected into the conversation.
422    ///
423    /// Set by SwitchParadigm nodes, or initialized from the AgentLoop's
424    /// active paradigm when the graph starts executing.
425    #[serde(default)]
426    pub active_paradigm: Option<String>,
427
428    /// The current iteration count in the graph walk.
429    ///
430    /// Incremented each time the executor processes a node. Enables
431    /// IterationExceeds edge conditions for loop safety (e.g., "route
432    /// to error_handler if iterations > 20").
433    #[serde(default)]
434    pub iteration_count: usize,
435
436    /// Remaining token budget (in tokens).
437    ///
438    /// Decremented after each LLM inference. When it reaches 0,
439    /// the graph should terminate (budget exhaustion).
440    #[serde(default)]
441    pub token_budget_remaining: u32,
442
443    /// Accumulated interrupt points during execution.
444    ///
445    /// When a HumanApproval node is encountered (with `interrupt: true`),
446    /// the executor records the interrupt point here. These are also
447    /// included in `GraphExecutionResult.interrupt_checkpoints`.
448    #[serde(default)]
449    pub interrupt_points: Vec<oneai_core::InterruptPoint>,
450}
451
452impl GraphState {
453    /// Create a new empty graph state.
454    pub fn new() -> Self {
455        Self {
456            conversation: oneai_core::Conversation::new(),
457            variables: HashMap::new(),
458            last_result: None,
459            last_error: None,
460            should_terminate: false,
461            metadata: HashMap::new(),
462            parsed_decision: None,
463            active_paradigm: None,
464            iteration_count: 0,
465            token_budget_remaining: 0, // 0 means "no budget limit set"
466            interrupt_points: Vec::new(),
467        }
468    }
469
470    /// Create a graph state with a token budget.
471    pub fn with_budget(budget: u32) -> Self {
472        Self {
473            token_budget_remaining: budget,
474            ..Self::new()
475        }
476    }
477}
478
479impl Default for GraphState {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485// ─── GraphExecutionResult ───────────────────────────────────────────────────
486
487/// The result of executing a StateGraph.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct GraphExecutionResult {
490    /// The graph name.
491    pub name: String,
492
493    /// The final state after execution.
494    pub final_state: GraphState,
495
496    /// Whether execution completed successfully (reached a terminal node).
497    pub completed: bool,
498
499    /// The terminal node reached (if completed).
500    pub terminal_node: Option<String>,
501
502    /// Number of node executions (iterations through the graph).
503    pub iterations: usize,
504
505    /// Checkpoint ID at each interrupt point (for resuming).
506    pub interrupt_checkpoints: Vec<String>,
507}