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