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}