Skip to main content

oneai_workflow/
state_executor.rs

1//! StateGraph executor — walks cyclic graph, evaluates edge conditions, handles interrupts.
2//!
3//! Unlike WorkflowDag (which is acyclic and runs levels sequentially),
4//! StateGraph supports cyclic edges for iterative agent patterns like ReAct loops.
5//! The executor walks the graph from the entry point, executing each node's action,
6//! evaluating outgoing edge conditions to route to the next node, and handling
7//! interrupt points where execution can pause for human intervention.
8//!
9//! Key features:
10//! - Walks graph from `entry_point` through conditional edges
11//! - Executes 6 NodeAction variants (LlmInfer, ToolCall, Delegate, HumanApproval, ConditionCheck, SwitchParadigm)
12//! - Evaluates 9 EdgeCondition variants for dynamic routing (including ParadigmEquals, IterationExceeds)
13//! - Handles interrupt points (nodes with `interrupt: true`)
14//! - Bounded by `max_iterations` to prevent infinite loops
15//! - Supports `{{variable}}` template interpolation in tool_name and args_template
16//!
17//! P2-2: GraphActionExecutor bridge
18//! ------------------------------
19//! The `GraphActionExecutor` trait enables AgentLoop integration — when a
20//! concrete implementation (from `oneai-agent`) is provided, LlmInfer and
21//! ToolCall nodes delegate to the AgentLoop's full pipeline (hooks, permission,
22//! domain pack, tool definitions). This makes StateGraph execution a first-class
23//! execution mode of the AgentLoop, not a separate disconnected system.
24//!
25//! The `DirectProviderActionExecutor` provides backward-compatible behavior
26//! (direct provider.infer() + tool.execute() without AgentLoop integration).
27
28use std::collections::HashMap;
29use std::sync::Arc;
30
31use oneai_core::error::{OneAIError, Result};
32use oneai_core::traits::{InteractionGate, LlmProvider, Tool};
33use oneai_core::{InferenceRequest, InferenceResponse, Message, Role};
34
35use crate::state_graph::{
36    StateGraph, GraphState, GraphEdge, NodeAction, EdgeCondition, GraphExecutionResult,
37};
38
39// ─── Delegate Action Trait ────────────────────────────────────────────────────
40
41/// Trait for executing delegate actions in StateGraph nodes.
42///
43/// This is a lightweight abstraction that avoids a cyclic dependency
44/// with `oneai-agent`. The `oneai-agent` crate provides a concrete
45/// implementation that wraps `SubAgentFactory`.
46///
47/// When no delegate factory is available, use `NoopDelegateFactory`
48/// which returns an error for all delegate requests.
49#[async_trait::async_trait]
50pub trait DelegateFactory: Send + Sync {
51    /// Execute a delegate action with the given agent kind and task.
52    async fn delegate(&self, agent_kind: &str, task: &str) -> Result<String>;
53}
54
55/// A no-op delegate factory that returns errors for all requests.
56/// Used when no sub-agent delegation is available.
57pub struct NoopDelegateFactory;
58
59#[async_trait::async_trait]
60impl DelegateFactory for NoopDelegateFactory {
61    async fn delegate(&self, agent_kind: &str, _task: &str) -> Result<String> {
62        Err(OneAIError::Workflow(format!(
63            "Delegate action '{}' not supported — no DelegateFactory configured", agent_kind
64        )))
65    }
66}
67
68// ─── ActionResult ────────────────────────────────────────────────────────────
69
70/// The result of executing a single node action.
71#[derive(Debug, Clone)]
72pub struct ActionResult {
73    /// The output content from this action.
74    pub output: String,
75    /// Error message if the action failed.
76    pub error: Option<String>,
77}
78
79// ─── GraphActionExecutor Trait ──────────────────────────────────────────────────
80
81/// Trait for executing graph node actions with full AgentLoop integration.
82///
83/// This is the P2-2 bridge — when a concrete implementation (from `oneai-agent`)
84/// is provided, the StateGraphExecutor delegates LlmInfer and ToolCall nodes
85/// to the AgentLoop's full pipeline instead of directly calling provider.infer()
86/// and tool.execute(). This means:
87///
88/// - **LlmInfer**: Gets proper tool definitions (filtered by paradigm config),
89///   domain pack tool decorators, PreInfer/PostInfer hooks, context assembly,
90///   and the OutputParser for decision parsing.
91/// - **ToolCall**: Gets PreToolUse/PostToolUse hooks, domain permission checks,
92///   approval gate interaction, and error recovery.
93/// - **SwitchParadigm**: Changes the active paradigm (updates tool filter
94///   and system prompt for subsequent nodes).
95///
96/// The `DirectProviderActionExecutor` provides backward-compatible behavior
97/// (direct provider + tool execution, no AgentLoop integration). It's used
98/// when no AgentLoop is available (e.g., standalone StateGraph execution).
99#[async_trait::async_trait]
100pub trait GraphActionExecutor: Send + Sync {
101    /// Execute an LLM inference node — using full AgentLoop infrastructure.
102    ///
103    /// When `include_tool_definitions` is true, builds tool definitions based on
104    /// `tool_filter_override` or the active paradigm's tool set. This is critical
105    /// for ReAct loops — the model needs tools to decide whether to call them.
106    ///
107    /// After inference, the response is parsed into a `GraphDecision` and stored
108    /// in `state.parsed_decision` for edge condition routing.
109    async fn execute_llm_infer(
110        &self,
111        action: &NodeAction,
112        state: &mut GraphState,
113    ) -> Result<ActionResult>;
114
115    /// Execute a tool call node — using AgentLoop's permission and hooks.
116    ///
117    /// When a GraphActionExecutor from `oneai-agent` is used, this method
118    /// runs PreToolUse hooks, checks domain permissions, interacts with the
119    /// approval gate, executes the tool, runs PostToolUse hooks, and handles
120    /// error recovery.
121    async fn execute_tool_call(
122        &self,
123        tool_name: &str,
124        args: &serde_json::Value,
125        state: &mut GraphState,
126    ) -> Result<ActionResult>;
127
128    /// Execute a paradigm switch node.
129    ///
130    /// Updates `state.active_paradigm` and clears `state.parsed_decision`.
131    /// Subsequent LlmInfer nodes will use the new paradigm's tool set
132    /// and system prompt.
133    async fn execute_paradigm_switch(
134        &self,
135        paradigm: &str,
136        state: &mut GraphState,
137    ) -> Result<ActionResult>;
138
139    /// Parse an LLM response into a GraphDecision.
140    ///
141    /// Uses the same OutputParser as the AgentLoop for consistent
142    /// decision parsing. Stores the result in `state.parsed_decision`.
143    async fn parse_decision(
144        &self,
145        response: &InferenceResponse,
146        state: &mut GraphState,
147    ) -> Result<oneai_core::GraphDecision>;
148}
149
150// ─── DirectProviderActionExecutor ──────────────────────────────────────────────
151
152/// Backward-compatible GraphActionExecutor that directly calls provider + tools.
153///
154/// This is the "no AgentLoop integration" path — used when StateGraphExecutor
155/// is constructed via `with_direct_provider()`. It mimics the original behavior:
156/// - LlmInfer: calls provider.infer() with a basic request (no hooks, no domain pack)
157/// - ToolCall: calls tool.execute() directly (no permission, no hooks)
158/// - SwitchParadigm: updates state.active_paradigm (no paradigm config)
159/// - parse_decision: simple ContentBlock-based parsing
160///
161/// For full AgentLoop integration, use `AgentLoopGraphActionExecutor` from
162/// `oneai-agent`.
163pub struct DirectProviderActionExecutor {
164    provider: Arc<dyn LlmProvider>,
165    tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
166}
167
168impl DirectProviderActionExecutor {
169    /// Create a new direct executor with provider and tools.
170    pub fn new(
171        provider: Arc<dyn LlmProvider>,
172        tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
173    ) -> Self {
174        Self { provider, tools }
175    }
176}
177
178#[async_trait::async_trait]
179impl GraphActionExecutor for DirectProviderActionExecutor {
180    async fn execute_llm_infer(
181        &self,
182        action: &NodeAction,
183        state: &mut GraphState,
184    ) -> Result<ActionResult> {
185        // Extract LlmInfer fields
186        let (system_prompt_override, _use_streaming, include_tool_definitions,
187             tool_filter_override, thinking_budget, temperature, max_tokens) = match action {
188            NodeAction::LlmInfer {
189                system_prompt_override, use_streaming, include_tool_definitions,
190                tool_filter_override, thinking_budget, temperature, max_tokens,
191            } => (
192                system_prompt_override.clone(),
193                *use_streaming,
194                *include_tool_definitions,
195                tool_filter_override.clone(),
196                *thinking_budget,
197                *temperature,
198                *max_tokens,
199            ),
200            _ => return Err(OneAIError::Workflow("Expected LlmInfer action".to_string())),
201        };
202
203        // Build system prompt
204        let system_prompt = system_prompt_override
205            .unwrap_or_else(|| "You are an intelligent agent. Respond to the task.".to_string());
206
207        let mut conversation = state.conversation.clone();
208        if !conversation.messages.iter().any(|m| m.role == Role::System) {
209            conversation.add_message(Message::system(&system_prompt));
210        }
211
212        // Build tool definitions if requested
213        let tool_defs = if include_tool_definitions {
214            let tools_map = self.tools.read().await;
215            if let Some(filter) = &tool_filter_override {
216                // Filter: only include specified tools
217                tools_map.values()
218                    .filter(|t| filter.contains(&t.name().to_string()))
219                    .map(|t| oneai_core::ToolDefinition {
220                        name: t.name().to_string(),
221                        description: t.description().to_string(),
222                        parameters_schema: t.parameters_schema(),
223                    })
224                    .collect()
225            } else {
226                // No filter — include all tools
227                tools_map.values().map(|t| oneai_core::ToolDefinition {
228                    name: t.name().to_string(),
229                    description: t.description().to_string(),
230                    parameters_schema: t.parameters_schema(),
231                }).collect()
232            }
233        } else {
234            vec![] // No tool definitions — pure text prompt
235        };
236
237        let request = InferenceRequest {
238            conversation,
239            tools: tool_defs,
240            max_tokens: max_tokens.or(Some(4096)),
241            temperature: temperature.or(Some(0.3)),
242            top_p: None,
243            stop_sequences: vec![],
244            constrained_output: None,
245            thinking_budget,
246            metadata: HashMap::new(),
247        };
248
249        let response = self.provider.infer(request).await?;
250        let output = response.message.text_content();
251
252        // Update conversation state
253        state.conversation.add_message(response.message.clone());
254
255        // Parse decision and store in state
256        let _decision = self.parse_decision(&response, state).await?;
257
258        Ok(ActionResult {
259            output,
260            error: None,
261        })
262    }
263
264    async fn execute_tool_call(
265        &self,
266        tool_name: &str,
267        args: &serde_json::Value,
268        state: &mut GraphState,
269    ) -> Result<ActionResult> {
270        let tools_map = self.tools.read().await;
271        let tool = tools_map.get(tool_name)
272            .ok_or_else(|| OneAIError::Workflow(
273                format!("Tool '{}' not found for ToolCall node", tool_name)
274            ))?;
275
276        let output = tool.execute(args.clone()).await?;
277
278        state.conversation.add_message(Message::tool_result(
279            format!("graph_tool_{}", tool_name),
280            output.content.clone(),
281        ));
282
283        Ok(ActionResult {
284            output: output.content,
285            error: output.error,
286        })
287    }
288
289    async fn execute_paradigm_switch(
290        &self,
291        paradigm: &str,
292        state: &mut GraphState,
293    ) -> Result<ActionResult> {
294        state.active_paradigm = Some(paradigm.to_string());
295        state.parsed_decision = None; // Clear — new inference needed
296
297        Ok(ActionResult {
298            output: format!("Paradigm switched to: {}", paradigm),
299            error: None,
300        })
301    }
302
303    async fn parse_decision(
304        &self,
305        response: &InferenceResponse,
306        state: &mut GraphState,
307    ) -> Result<oneai_core::GraphDecision> {
308        // Simple ContentBlock-based parsing (mirrors the AgentLoop's parse_decision logic
309        // but produces GraphDecision instead of AgentDecision)
310        let mut tool_calls = Vec::new();
311        let mut text_parts = Vec::new();
312
313        for block in &response.message.content {
314            match block {
315                oneai_core::ContentBlock::ToolCall { id: _, name, args } => {
316                    // Check for special internal tools (delegate, switch_paradigm)
317                    if name == "delegate" {
318                        // Parse delegate args
319                        let args_value: serde_json::Value = serde_json::from_str(args)
320                            .unwrap_or_else(|_| serde_json::json!({}));
321                        let agent_kind = args_value.get("agent_type")
322                            .and_then(|v| v.as_str()).unwrap_or("Explore").to_string();
323                        let task = args_value.get("task")
324                            .and_then(|v| v.as_str()).unwrap_or("").to_string();
325                        let decision = oneai_core::GraphDecision::Delegate {
326                            agent_kind,
327                            task,
328                        };
329                        state.parsed_decision = Some(decision.clone());
330                        return Ok(decision);
331                    }
332                    if name == "switch_paradigm" {
333                        let args_value: serde_json::Value = serde_json::from_str(args)
334                            .unwrap_or_else(|_| serde_json::json!({}));
335                        let paradigm = args_value.get("paradigm")
336                            .and_then(|v| v.as_str()).unwrap_or("react").to_string();
337                        let decision = oneai_core::GraphDecision::SwitchParadigm { paradigm };
338                        state.parsed_decision = Some(decision.clone());
339                        return Ok(decision);
340                    }
341                    tool_calls.push(name.clone());
342                }
343                oneai_core::ContentBlock::Text { text } => {
344                    text_parts.push(text.clone());
345                }
346                _ => {}
347            }
348        }
349
350        let decision = if !tool_calls.is_empty() {
351            oneai_core::GraphDecision::ToolCalls {
352                count: tool_calls.len(),
353            }
354        } else {
355            oneai_core::GraphDecision::DirectAnswer {
356                text: text_parts.join("\n"),
357            }
358        };
359
360        state.parsed_decision = Some(decision.clone());
361        Ok(decision)
362    }
363}
364
365// ─── StateGraphExecutor ──────────────────────────────────────────────────────
366
367/// Executor for StateGraph — walks cyclic graph with conditional routing.
368///
369/// The executor processes a StateGraph by:
370/// 1. Starting at the `entry_point` node
371/// 2. Executing each node's `NodeAction` via the `GraphActionExecutor`
372/// 3. Evaluating outgoing `EdgeCondition`s to select the next node
373/// 4. Handling interrupt points (nodes marked with `interrupt: true`)
374/// 5. Terminating when a terminal node is reached or max iterations exceeded
375///
376/// P2-2: The executor now uses a `GraphActionExecutor` for node action execution.
377/// This enables AgentLoop integration — when an `AgentLoopGraphActionExecutor`
378/// (from `oneai-agent`) is provided, LlmInfer/ToolCall nodes delegate to the
379/// AgentLoop's full pipeline (hooks, permission, domain pack, tool definitions).
380///
381/// Template interpolation (`{{variable}}`) is still applied to `tool_name` and
382/// `args_template` fields before tool execution, using `GraphState.variables`.
383pub struct StateGraphExecutor {
384    /// Action executor — delegates node action execution.
385    /// Can be DirectProviderActionExecutor (backward compat) or
386    /// AgentLoopGraphActionExecutor (full AgentLoop integration).
387    action_executor: Arc<dyn GraphActionExecutor>,
388    /// Delegate factory for Delegate nodes.
389    delegate_factory: Arc<dyn DelegateFactory>,
390    /// Approval gate for interrupt points and HumanApproval nodes.
391    interaction_gate: Arc<dyn InteractionGate>,
392    /// Maximum iterations through the graph (prevents infinite loops).
393    /// Default: 50.
394    max_iterations: usize,
395}
396
397impl StateGraphExecutor {
398    /// Create a new StateGraphExecutor with a GraphActionExecutor.
399    ///
400    /// This is the P2-2 constructor — when an `AgentLoopGraphActionExecutor`
401    /// is provided, LlmInfer/ToolCall nodes get full AgentLoop integration.
402    pub fn new(
403        action_executor: Arc<dyn GraphActionExecutor>,
404        delegate_factory: Arc<dyn DelegateFactory>,
405        interaction_gate: Arc<dyn InteractionGate>,
406        max_iterations: usize,
407    ) -> Self {
408        Self {
409            action_executor,
410            delegate_factory,
411            interaction_gate,
412            max_iterations,
413        }
414    }
415
416    /// Create with default max_iterations (50).
417    pub fn with_defaults(
418        action_executor: Arc<dyn GraphActionExecutor>,
419        delegate_factory: Arc<dyn DelegateFactory>,
420        interaction_gate: Arc<dyn InteractionGate>,
421    ) -> Self {
422        Self::new(action_executor, delegate_factory, interaction_gate, 50)
423    }
424
425    /// Create with direct provider + tools (backward-compatible constructor).
426    ///
427    /// This constructor creates a `DirectProviderActionExecutor` internally,
428    /// providing the same behavior as the original StateGraphExecutor (before P2-2).
429    /// Use this when you don't have an AgentLoop available (e.g., standalone
430    /// StateGraph execution without AgentLoop integration).
431    pub fn with_direct_provider(
432        provider: Arc<dyn LlmProvider>,
433        tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
434        delegate_factory: Arc<dyn DelegateFactory>,
435        interaction_gate: Arc<dyn InteractionGate>,
436        max_iterations: usize,
437    ) -> Self {
438        let action_executor = Arc::new(DirectProviderActionExecutor::new(provider, tools));
439        Self::new(action_executor, delegate_factory, interaction_gate, max_iterations)
440    }
441
442    /// Create with direct provider + default max_iterations (50).
443    /// Backward-compatible with the original `with_defaults()` constructor.
444    pub fn with_direct_provider_defaults(
445        provider: Arc<dyn LlmProvider>,
446        tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
447        delegate_factory: Arc<dyn DelegateFactory>,
448        interaction_gate: Arc<dyn InteractionGate>,
449    ) -> Self {
450        Self::with_direct_provider(provider, tools, delegate_factory, interaction_gate, 50)
451    }
452
453    /// Execute a StateGraph starting from its entry point.
454    ///
455    /// Walks the graph, executing each node's action and routing to the
456    /// next node based on edge conditions. Returns a GraphExecutionResult
457    /// with the final state, completion status, and iteration count.
458    pub async fn execute(
459        &self,
460        graph: &StateGraph,
461        initial_state: GraphState,
462    ) -> Result<GraphExecutionResult> {
463        let mut state = initial_state;
464        let mut current_node_id = graph.entry_point.clone();
465        let mut iterations = 0;
466        let mut interrupt_checkpoints = Vec::new();
467
468        while iterations < self.max_iterations {
469            iterations += 1;
470            state.iteration_count = iterations;
471
472            // 1. Get the current node
473            let node = graph.get_node(&current_node_id)
474                .ok_or_else(|| OneAIError::Workflow(
475                    format!("Node '{}' not found in graph '{}'", current_node_id, graph.name)
476                ))?;
477
478            // 2. Check if this is a terminal node
479            if graph.terminal_nodes.contains(&current_node_id) {
480                let action_result = self.execute_node_action(&node.action, &mut state).await?;
481                state.last_result = Some(action_result.output.clone());
482                state.last_error = action_result.error.clone();
483
484                return Ok(GraphExecutionResult {
485                    name: graph.name.clone(),
486                    final_state: state,
487                    completed: true,
488                    terminal_node: Some(current_node_id),
489                    iterations,
490                    interrupt_checkpoints,
491                });
492            }
493
494            // 3. Check interrupt point
495            if node.interrupt {
496                let checkpoint_id = format!("interrupt_{}_{}", graph.name, current_node_id);
497                interrupt_checkpoints.push(checkpoint_id.clone());
498
499                // Request human approval
500                let approval_request = oneai_core::ApprovalRequest {
501                    tool_name: "state_graph_interrupt".into(),
502                    args: serde_json::json!({
503                        "node": current_node_id,
504                        "description": match &node.action {
505                            NodeAction::HumanApproval { description } => description.clone(),
506                            _ => "Interrupt point reached".to_string(),
507                        },
508                        "state": state.variables,
509                    }),
510                    risk_level: oneai_core::RiskLevel::Medium,
511                    permission_level: Some(oneai_core::PermissionLevel::Standard),
512                    justification: format!("StateGraph interrupt at node '{}' in graph '{}'",
513                        current_node_id, graph.name),
514                };
515
516                let approval = self
517                    .interaction_gate
518                    .request(oneai_core::InteractionRequest::ToolApproval {
519                        approval: approval_request,
520                    })
521                    .await?;
522                match approval {
523                    oneai_core::InteractionResponse::Abort { reason } => {
524                        state.should_terminate = true;
525                        state.last_error = Some(format!("Interrupt denied: {}", reason));
526                        return Ok(GraphExecutionResult {
527                            name: graph.name.clone(),
528                            final_state: state,
529                            completed: false,
530                            terminal_node: None,
531                            iterations,
532                            interrupt_checkpoints,
533                        });
534                    }
535                    oneai_core::InteractionResponse::Revise { feedback } => {
536                        // Stateless graph path can't loop on feedback → deny.
537                        state.should_terminate = true;
538                        state.last_error = Some(format!("Interrupt denied: {}", feedback));
539                        return Ok(GraphExecutionResult {
540                            name: graph.name.clone(),
541                            final_state: state,
542                            completed: false,
543                            terminal_node: None,
544                            iterations,
545                            interrupt_checkpoints,
546                        });
547                    }
548                    _ => { /* Proceed / ProceedWith / Choose — continue execution */ }
549                }
550            }
551
552            // 4. Execute node action
553            let action_result = self.execute_node_action(&node.action, &mut state).await?;
554
555            // 5. Update state
556            state.last_result = Some(action_result.output.clone());
557            state.last_error = action_result.error.clone();
558            if state.should_terminate {
559                break;
560            }
561
562            // 6. Evaluate outgoing edges → route to next node
563            let edges = graph.get_edges_from(&current_node_id);
564            let next_node = self.route_next_node(&edges, &state)?;
565
566            if next_node.is_none() {
567                // No matching condition — terminate
568                tracing::warn!(
569                    "No matching edge condition from node '{}' in graph '{}'. Terminating.",
570                    current_node_id, graph.name
571                );
572                break;
573            }
574
575            current_node_id = next_node.unwrap();
576        }
577
578        // Did we exceed max_iterations or terminate without reaching a terminal node?
579        let should_terminate = state.should_terminate;
580        if iterations >= self.max_iterations {
581            tracing::warn!(
582                "StateGraph '{}' exceeded max iterations ({}). Terminating.",
583                graph.name, self.max_iterations
584            );
585        }
586
587        Ok(GraphExecutionResult {
588            name: graph.name.clone(),
589            final_state: state,
590            completed: !should_terminate,
591            terminal_node: None,
592            iterations,
593            interrupt_checkpoints,
594        })
595    }
596
597    /// Execute a node's action and update the state.
598    ///
599    /// P2-2: LlmInfer and ToolCall nodes delegate to the GraphActionExecutor,
600    /// which may be an AgentLoopGraphActionExecutor (full AgentLoop integration)
601    /// or a DirectProviderActionExecutor (backward compat).
602    async fn execute_node_action(
603        &self,
604        action: &NodeAction,
605        state: &mut GraphState,
606    ) -> Result<ActionResult> {
607        match action {
608            NodeAction::LlmInfer { .. } => {
609                // Delegate to GraphActionExecutor — which may build tool definitions,
610                // run hooks, use domain pack, and parse the response into GraphDecision.
611                self.action_executor.execute_llm_infer(action, state).await
612            }
613
614            NodeAction::ToolCall { tool_name, args_template } => {
615                // Template interpolation: {{variable}} → state.variables[key]
616                let resolved_name = interpolate_graph_template(tool_name, &state.variables);
617                let resolved_args = if let Some(template) = args_template {
618                    let json_str = interpolate_graph_template(template, &state.variables);
619                    serde_json::from_str(&json_str)
620                        .unwrap_or(serde_json::json!({}))
621                } else {
622                    serde_json::json!({})
623                };
624
625                // Delegate to GraphActionExecutor — which may run hooks,
626                // check permissions, interact with approval gate.
627                self.action_executor.execute_tool_call(&resolved_name, &resolved_args, state).await
628            }
629
630            NodeAction::Delegate { agent_kind, task_template } => {
631                let task = interpolate_graph_template(task_template, &state.variables);
632
633                let result = self.delegate_factory.delegate(agent_kind, &task).await?;
634
635                // Append delegate result to conversation
636                state.conversation.add_message(Message::assistant(
637                    format!("[Delegate {}]: {}", agent_kind, result)
638                ));
639
640                // Set parsed_decision to Delegate
641                state.parsed_decision = Some(oneai_core::GraphDecision::Delegate {
642                    agent_kind: agent_kind.clone(),
643                    task,
644                });
645
646                Ok(ActionResult {
647                    output: result,
648                    error: None,
649                })
650            }
651
652            NodeAction::HumanApproval { description } => {
653                // Handled in the main loop's interrupt check.
654                // Here we just return the description as output.
655                Ok(ActionResult {
656                    output: description.clone(),
657                    error: None,
658                })
659            }
660
661            NodeAction::ConditionCheck { condition } => {
662                let result = self.evaluate_condition_expression(condition, state)?;
663                state.variables.insert("_condition_result".to_string(), result.to_string());
664                Ok(ActionResult {
665                    output: result.to_string(),
666                    error: None,
667                })
668            }
669
670            NodeAction::SwitchParadigm { paradigm } => {
671                // Delegate to GraphActionExecutor — updates state.active_paradigm
672                self.action_executor.execute_paradigm_switch(paradigm, state).await
673            }
674        }
675    }
676
677    /// Route to the next node by evaluating outgoing edge conditions.
678    ///
679    /// Priority: conditional edges are evaluated in order; the first matching
680    /// condition determines the next node. Unconditional edges (Always) act
681    /// as fallback routing.
682    fn route_next_node(
683        &self,
684        edges: &[&GraphEdge],
685        state: &GraphState,
686    ) -> Result<Option<String>> {
687        for edge in edges {
688            if let Some(condition) = &edge.condition {
689                if self.evaluate_edge_condition(condition, state)? {
690                    return Ok(Some(edge.to.clone()));
691                }
692            } else {
693                // No condition — unconditional edge (fallback)
694                return Ok(Some(edge.to.clone()));
695            }
696        }
697        Ok(None)
698    }
699
700    /// Evaluate an edge condition against the current state.
701    ///
702    /// P2-2: HasToolCalls, IsFinalAnswer, and RequestsDelegation now evaluate
703    /// against `state.parsed_decision` (a structured `GraphDecision`) rather
704    /// than unreliable string pattern matching. This makes edge routing
705    /// consistent with the AgentLoop's decision parsing.
706    fn evaluate_edge_condition(
707        &self,
708        condition: &EdgeCondition,
709        state: &GraphState,
710    ) -> Result<bool> {
711        match condition {
712            EdgeCondition::HasToolCalls => {
713                // P2-2: Use parsed_decision instead of string matching
714                Ok(state.parsed_decision.as_ref()
715                    .map(|d| d.has_tool_calls())
716                    .unwrap_or(false))
717            }
718
719            EdgeCondition::IsFinalAnswer => {
720                // P2-2: Use parsed_decision instead of !HasToolCalls
721                Ok(state.parsed_decision.as_ref()
722                    .map(|d| d.is_final())
723                    .unwrap_or(false))
724            }
725
726            EdgeCondition::RequestsDelegation => {
727                // P2-2: Use parsed_decision
728                Ok(state.parsed_decision.as_ref()
729                    .map(|d| d.is_delegation())
730                    .unwrap_or(false))
731            }
732
733            EdgeCondition::ErrorOccurred => {
734                Ok(state.last_error.is_some())
735            }
736
737            EdgeCondition::StateEquals { variable, value } => {
738                Ok(state.variables.get(variable) == Some(value))
739            }
740
741            EdgeCondition::Always => Ok(true),
742
743            EdgeCondition::Custom { name, description } => {
744                tracing::warn!(
745                    "Custom condition '{}' ('{}') not registered. Defaulting to false.",
746                    name, description
747                );
748                Ok(false)
749            }
750
751            EdgeCondition::ParadigmEquals { paradigm } => {
752                Ok(state.active_paradigm.as_ref() == Some(paradigm))
753            }
754
755            EdgeCondition::IterationExceeds { count } => {
756                Ok(state.iteration_count > *count)
757            }
758        }
759    }
760
761    /// Evaluate a condition expression (for ConditionCheck nodes).
762    ///
763    /// Simple condition expressions:
764    /// - "has_tool_calls" → checks parsed_decision for ToolCalls
765    /// - "is_final_answer" → checks parsed_decision for DirectAnswer
766    /// - "error_occurred" → checks if last_error is set
767    /// - "variable==value" → checks state variable equality
768    fn evaluate_condition_expression(&self, condition: &str, state: &GraphState) -> Result<bool> {
769        if condition == "has_tool_calls" {
770            return self.evaluate_edge_condition(&EdgeCondition::HasToolCalls, state);
771        }
772        if condition == "is_final_answer" {
773            return self.evaluate_edge_condition(&EdgeCondition::IsFinalAnswer, state);
774        }
775        if condition == "error_occurred" {
776            return self.evaluate_edge_condition(&EdgeCondition::ErrorOccurred, state);
777        }
778        // "variable==value" pattern
779        if let Some((var, val)) = condition.split_once("==") {
780            return Ok(state.variables.get(var.trim()) == Some(&val.trim().to_string()));
781        }
782        // Fallback: treat as a state variable lookup (truthy check)
783        Ok(state.variables.get(condition).map(|v| v == "true").unwrap_or(false))
784    }
785}
786
787// ─── Template Interpolation ────────────────────────────────────────────────
788
789/// Interpolate `{{variable}}` template patterns for StateGraph execution.
790///
791/// Uses `GraphState.variables` as the substitution source.
792/// Replaces `{{key}}` with the corresponding value from the variables map.
793pub fn interpolate_graph_template(template: &str, variables: &HashMap<String, String>) -> String {
794    let mut result = template.to_string();
795    for (key, value) in variables {
796        result = result.replace(&format!("{{{{{}}}}}", key), value);
797    }
798    result
799}
800
801// ─── Tests ──────────────────────────────────────────────────────────────────
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use crate::state_graph::{StateGraph, GraphNode};
807
808    #[allow(dead_code)] // test fixture retained for future executor coverage
809    fn make_simple_graph() -> StateGraph {
810        // Simple linear graph: start → process → end
811        let mut graph = StateGraph::new("test", "start");
812
813        graph.add_node(GraphNode {
814            id: "start".to_string(),
815            action: NodeAction::ConditionCheck {
816                condition: "ready==true".to_string(),
817            },
818            interrupt: false,
819            metadata: HashMap::new(),
820        });
821
822        graph.add_node(GraphNode {
823            id: "end".to_string(),
824            action: NodeAction::LlmInfer {
825                system_prompt_override: Some("Final answer".to_string()),
826                use_streaming: false,
827                include_tool_definitions: false,
828                tool_filter_override: None,
829                thinking_budget: None,
830                temperature: None,
831                max_tokens: None,
832            },
833            interrupt: false,
834            metadata: HashMap::new(),
835        });
836
837        graph.add_edge(GraphEdge {
838            from: "start".to_string(),
839            to: "end".to_string(),
840            condition: Some(EdgeCondition::Always),
841            metadata: HashMap::new(),
842        });
843
844        graph.add_terminal("end".to_string());
845
846        graph
847    }
848
849    #[test]
850    fn test_evaluate_edge_condition_always() {
851        let state = GraphState::new();
852        let executor = make_test_executor();
853
854        let result = executor.evaluate_edge_condition(&EdgeCondition::Always, &state).unwrap();
855        assert!(result);
856    }
857
858    #[test]
859    fn test_evaluate_edge_condition_error_occurred() {
860        let mut state = GraphState::new();
861        let executor = make_test_executor();
862
863        // No error → false
864        assert!(!executor.evaluate_edge_condition(&EdgeCondition::ErrorOccurred, &state).unwrap());
865
866        // With error → true
867        state.last_error = Some("test error".to_string());
868        assert!(executor.evaluate_edge_condition(&EdgeCondition::ErrorOccurred, &state).unwrap());
869    }
870
871    #[test]
872    fn test_evaluate_edge_condition_state_equals() {
873        let mut state = GraphState::new();
874        let executor = make_test_executor();
875
876        // Variable not set → false
877        let cond = EdgeCondition::StateEquals { variable: "mode".to_string(), value: "react".to_string() };
878        assert!(!executor.evaluate_edge_condition(&cond, &state).unwrap());
879
880        // Variable set but different value → false
881        state.variables.insert("mode".to_string(), "plan".to_string());
882        assert!(!executor.evaluate_edge_condition(&cond, &state).unwrap());
883
884        // Variable matches → true
885        state.variables.insert("mode".to_string(), "react".to_string());
886        assert!(executor.evaluate_edge_condition(&cond, &state).unwrap());
887    }
888
889    #[test]
890    fn test_evaluate_edge_condition_has_tool_calls() {
891        let mut state = GraphState::new();
892        let executor = make_test_executor();
893
894        // No parsed_decision → false
895        assert!(!executor.evaluate_edge_condition(&EdgeCondition::HasToolCalls, &state).unwrap());
896
897        // DirectAnswer → false
898        state.parsed_decision = Some(oneai_core::GraphDecision::DirectAnswer {
899            text: "Just a text answer".to_string(),
900        });
901        assert!(!executor.evaluate_edge_condition(&EdgeCondition::HasToolCalls, &state).unwrap());
902
903        // ToolCalls → true
904        state.parsed_decision = Some(oneai_core::GraphDecision::ToolCalls { count: 1 });
905        assert!(executor.evaluate_edge_condition(&EdgeCondition::HasToolCalls, &state).unwrap());
906    }
907
908    #[test]
909    fn test_evaluate_edge_condition_is_final_answer() {
910        let mut state = GraphState::new();
911        let executor = make_test_executor();
912
913        // No parsed_decision → false
914        assert!(!executor.evaluate_edge_condition(&EdgeCondition::IsFinalAnswer, &state).unwrap());
915
916        // DirectAnswer → true
917        state.parsed_decision = Some(oneai_core::GraphDecision::DirectAnswer {
918            text: "The answer is 42".to_string(),
919        });
920        assert!(executor.evaluate_edge_condition(&EdgeCondition::IsFinalAnswer, &state).unwrap());
921
922        // ToolCalls → false
923        state.parsed_decision = Some(oneai_core::GraphDecision::ToolCalls { count: 2 });
924        assert!(!executor.evaluate_edge_condition(&EdgeCondition::IsFinalAnswer, &state).unwrap());
925    }
926
927    #[test]
928    fn test_evaluate_edge_condition_requests_delegation() {
929        let mut state = GraphState::new();
930        let executor = make_test_executor();
931
932        // Delegate decision → true
933        state.parsed_decision = Some(oneai_core::GraphDecision::Delegate {
934            agent_kind: "Explore".to_string(),
935            task: "Search the codebase".to_string(),
936        });
937        assert!(executor.evaluate_edge_condition(&EdgeCondition::RequestsDelegation, &state).unwrap());
938
939        // ToolCalls → false
940        state.parsed_decision = Some(oneai_core::GraphDecision::ToolCalls { count: 1 });
941        assert!(!executor.evaluate_edge_condition(&EdgeCondition::RequestsDelegation, &state).unwrap());
942    }
943
944    #[test]
945    fn test_evaluate_edge_condition_paradigm_equals() {
946        let mut state = GraphState::new();
947        let executor = make_test_executor();
948
949        // No paradigm → false
950        let cond = EdgeCondition::ParadigmEquals { paradigm: "react".to_string() };
951        assert!(!executor.evaluate_edge_condition(&cond, &state).unwrap());
952
953        // Wrong paradigm → false
954        state.active_paradigm = Some("plan".to_string());
955        assert!(!executor.evaluate_edge_condition(&cond, &state).unwrap());
956
957        // Matching paradigm → true
958        state.active_paradigm = Some("react".to_string());
959        assert!(executor.evaluate_edge_condition(&cond, &state).unwrap());
960    }
961
962    #[test]
963    fn test_evaluate_edge_condition_iteration_exceeds() {
964        let mut state = GraphState::new();
965        let executor = make_test_executor();
966
967        // iteration_count = 0, threshold = 5 → false
968        let cond = EdgeCondition::IterationExceeds { count: 5 };
969        assert!(!executor.evaluate_edge_condition(&cond, &state).unwrap());
970
971        // iteration_count = 10, threshold = 5 → true
972        state.iteration_count = 10;
973        assert!(executor.evaluate_edge_condition(&cond, &state).unwrap());
974    }
975
976    #[test]
977    fn test_interpolate_graph_template() {
978        let vars = HashMap::from([
979            ("selected_tool".to_string(), "shell".to_string()),
980            ("command".to_string(), "ls -la".to_string()),
981        ]);
982
983        let result = interpolate_graph_template("{{selected_tool}} with {{command}}", &vars);
984        assert_eq!(result, "shell with ls -la");
985    }
986
987    #[test]
988    fn test_evaluate_condition_expression() {
989        let mut state = GraphState::new();
990        let executor = make_test_executor();
991
992        // "variable==value" pattern
993        state.variables.insert("mode".to_string(), "react".to_string());
994        assert!(executor.evaluate_condition_expression("mode==react", &state).unwrap());
995        assert!(!executor.evaluate_condition_expression("mode==plan", &state).unwrap());
996
997        // "error_occurred" pattern
998        state.last_error = Some("error".to_string());
999        assert!(executor.evaluate_condition_expression("error_occurred", &state).unwrap());
1000    }
1001
1002    /// Create a test executor with mock dependencies (for condition testing only).
1003    /// Note: We can't test full execute() without a real provider,
1004    /// but we can test all the routing logic.
1005    #[allow(dead_code)] // reserved for future executor-iteration tests
1006    struct TestStateGraphExecutor {
1007        #[allow(dead_code)]
1008        max_iterations: usize,
1009    }
1010
1011    impl TestStateGraphExecutor {
1012        fn evaluate_edge_condition(&self, condition: &EdgeCondition, state: &GraphState) -> Result<bool> {
1013            match condition {
1014                EdgeCondition::HasToolCalls => {
1015                    Ok(state.parsed_decision.as_ref()
1016                        .map(|d| d.has_tool_calls())
1017                        .unwrap_or(false))
1018                }
1019                EdgeCondition::IsFinalAnswer => {
1020                    Ok(state.parsed_decision.as_ref()
1021                        .map(|d| d.is_final())
1022                        .unwrap_or(false))
1023                }
1024                EdgeCondition::RequestsDelegation => {
1025                    Ok(state.parsed_decision.as_ref()
1026                        .map(|d| d.is_delegation())
1027                        .unwrap_or(false))
1028                }
1029                EdgeCondition::ErrorOccurred => Ok(state.last_error.is_some()),
1030                EdgeCondition::StateEquals { variable, value } => {
1031                    Ok(state.variables.get(variable) == Some(value))
1032                }
1033                EdgeCondition::Always => Ok(true),
1034                EdgeCondition::Custom { name, .. } => {
1035                    tracing::warn!("Custom condition '{}' not registered, defaulting to false", name);
1036                    Ok(false)
1037                }
1038                EdgeCondition::ParadigmEquals { paradigm } => {
1039                    Ok(state.active_paradigm.as_ref() == Some(paradigm))
1040                }
1041                EdgeCondition::IterationExceeds { count } => {
1042                    Ok(state.iteration_count > *count)
1043                }
1044            }
1045        }
1046
1047        fn evaluate_condition_expression(&self, condition: &str, state: &GraphState) -> Result<bool> {
1048            if condition == "has_tool_calls" {
1049                return self.evaluate_edge_condition(&EdgeCondition::HasToolCalls, state);
1050            }
1051            if condition == "is_final_answer" {
1052                return self.evaluate_edge_condition(&EdgeCondition::IsFinalAnswer, state);
1053            }
1054            if condition == "error_occurred" {
1055                return self.evaluate_edge_condition(&EdgeCondition::ErrorOccurred, state);
1056            }
1057            if let Some((var, val)) = condition.split_once("==") {
1058                return Ok(state.variables.get(var.trim()) == Some(&val.trim().to_string()));
1059            }
1060            Ok(state.variables.get(condition).map(|v| v == "true").unwrap_or(false))
1061        }
1062    }
1063
1064    fn make_test_executor() -> TestStateGraphExecutor {
1065        TestStateGraphExecutor { max_iterations: 50 }
1066    }
1067
1068    #[test]
1069    fn test_graph_decision_enum() {
1070        let decision = oneai_core::GraphDecision::DirectAnswer {
1071            text: "42".to_string(),
1072        };
1073        assert!(decision.is_final());
1074        assert!(!decision.has_tool_calls());
1075
1076        let tool_calls = oneai_core::GraphDecision::ToolCalls { count: 2 };
1077        assert!(tool_calls.has_tool_calls());
1078        assert!(!tool_calls.is_final());
1079
1080        let delegate = oneai_core::GraphDecision::Delegate {
1081            agent_kind: "Explore".to_string(),
1082            task: "Search".to_string(),
1083        };
1084        assert!(delegate.is_delegation());
1085        assert!(!delegate.has_tool_calls());
1086
1087        let switch = oneai_core::GraphDecision::SwitchParadigm {
1088            paradigm: "plan".to_string(),
1089        };
1090        assert!(!switch.is_final());
1091        assert!(!switch.has_tool_calls());
1092    }
1093}