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}