Skip to main content

oneai_workflow/
executor.rs

1//! Workflow executor — runs compiled DAG with level-based parallel execution.
2//!
3//! The executor runs a WorkflowDag by:
4//! 1. Processing levels sequentially (level 0 → level 1 → ... → level N)
5//! 2. Within each level, running all steps concurrently (since they have no mutual dependencies)
6//! 3. Tracking step results and passing context between levels
7//! 4. Handling timeouts, retries, and approval gates
8//! 5. Supporting `{{variable}}` template interpolation in tool_args and prompt fields
9//! 6. Executing LLM inference for prompt-based steps when a provider is available
10//!
11//! Step results are accumulated in a WorkflowContext that flows between levels.
12//! Template interpolation replaces `{{step_id_output}}` and `{{step_id}}` with
13//! previous step outputs, and `{{variable_name}}` with context variables.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use serde::{Deserialize, Serialize};
19
20use oneai_core::error::{OneAIError, Result};
21use oneai_core::traits::{InteractionGate, LlmProvider, Tool};
22use oneai_core::{Conversation, InferenceRequest, Message};
23
24use crate::dag::WorkflowDag;
25use crate::config::{WorkflowConfig, RetryPolicy};
26
27/// The status of a workflow step execution.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum StepStatus {
31    /// Not yet started.
32    Pending,
33    /// Currently running.
34    Running,
35    /// Completed successfully.
36    Completed,
37    /// Failed (with optional error message).
38    Failed,
39    /// Skipped (a dependency failed and continue_on_failure is false).
40    Skipped,
41}
42
43/// The result of a single workflow step execution.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct StepResult {
46    /// The step ID.
47    pub step_id: String,
48
49    /// The status of this step.
50    pub status: StepStatus,
51
52    /// The output content (if completed).
53    pub output: Option<String>,
54
55    /// Error message (if failed).
56    pub error: Option<String>,
57
58    /// How many retries were used.
59    pub retries_used: usize,
60
61    /// Execution time in milliseconds.
62    pub execution_time_ms: Option<u64>,
63}
64
65/// The result of the entire workflow execution.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WorkflowResult {
68    /// The workflow name.
69    pub name: String,
70
71    /// The results of each step.
72    pub step_results: HashMap<String, StepResult>,
73
74    /// The final context variables after all steps.
75    pub variables: HashMap<String, String>,
76
77    /// Whether the workflow completed successfully.
78    pub success: bool,
79
80    /// Total execution time in milliseconds.
81    pub total_time_ms: u64,
82
83    /// Which level was being executed when the workflow ended.
84    pub completed_levels: usize,
85}
86
87impl WorkflowResult {
88    /// Get a step result by ID.
89    pub fn get_step_result(&self, step_id: &str) -> Option<&StepResult> {
90        self.step_results.get(step_id)
91    }
92
93    /// Get all completed step IDs.
94    pub fn completed_steps(&self) -> Vec<String> {
95        self.step_results.iter()
96            .filter(|(_, r)| r.status == StepStatus::Completed)
97            .map(|(id, _)| id.clone())
98            .collect()
99    }
100
101    /// Get all failed step IDs.
102    pub fn failed_steps(&self) -> Vec<String> {
103        self.step_results.iter()
104            .filter(|(_, r)| r.status == StepStatus::Failed)
105            .map(|(id, _)| id.clone())
106            .collect()
107    }
108}
109
110/// Workflow execution context — shared state that flows between levels.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct WorkflowContext {
113    /// Context variables (initially from WorkflowConfig, updated by steps).
114    pub variables: HashMap<String, String>,
115    /// Results from completed steps (available to subsequent steps).
116    pub step_outputs: HashMap<String, String>,
117}
118
119impl WorkflowContext {
120    /// Create a context from workflow config variables.
121    pub fn from_config(config: &WorkflowConfig) -> Self {
122        Self {
123            variables: config.variables.clone(),
124            step_outputs: HashMap::new(),
125        }
126    }
127
128    /// Set a variable.
129    pub fn set_variable(&mut self, key: impl Into<String>, value: impl Into<String>) {
130        self.variables.insert(key.into(), value.into());
131    }
132
133    /// Get a variable.
134    pub fn get_variable(&self, key: &str) -> Option<&String> {
135        self.variables.get(key)
136    }
137
138    /// Store a step's output.
139    pub fn set_step_output(&mut self, step_id: impl Into<String>, output: impl Into<String>) {
140        self.step_outputs.insert(step_id.into(), output.into());
141    }
142}
143
144/// Workflow executor — runs a compiled DAG.
145///
146/// The executor processes the DAG level by level:
147/// - Level 0: all root nodes run concurrently
148/// - Level 1: nodes that depend only on level 0 nodes run concurrently
149/// - Level N: nodes that depend only on earlier levels run concurrently
150///
151/// Between levels, the WorkflowContext is updated with step results.
152/// Template interpolation (`{{variable}}`) is applied to tool_args and
153/// prompt fields before step execution, using context variables and
154/// previous step outputs.
155///
156/// When a provider is available, prompt-based steps (steps with a `prompt`
157/// field but no `tool`) execute actual LLM inference instead of just
158/// returning the prompt text.
159pub struct WorkflowExecutor {
160    /// The tool registry for executing tool-based steps.
161    /// Uses RwLock so tools can be registered after construction.
162    tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
163    /// The approval gate for high-risk tool approval.
164    interaction_gate: Arc<dyn InteractionGate>,
165    /// Optional LLM provider for executing prompt-based steps.
166    /// When set, steps with a `prompt` field but no `tool` will call
167    /// the provider for actual inference. When None, prompt steps
168    /// just return the interpolated prompt text.
169    provider: Option<Arc<dyn LlmProvider>>,
170}
171
172impl WorkflowExecutor {
173    /// Create a new executor with tools and approval gate.
174    pub fn new(
175        tools: Arc<HashMap<String, Arc<dyn Tool>>>,
176        interaction_gate: Arc<dyn InteractionGate>,
177    ) -> Self {
178        // Convert static HashMap into RwLock-backed HashMap for dynamic registration
179        Self {
180            tools: Arc::new(tokio::sync::RwLock::new((*tools).clone())),
181            interaction_gate,
182            provider: None,
183        }
184    }
185
186    /// Create a new executor with an empty tool registry.
187    /// Tools can be registered later via `register_tool()`.
188    pub fn new_empty(interaction_gate: Arc<dyn InteractionGate>) -> Self {
189        Self {
190            tools: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
191            interaction_gate,
192            provider: None,
193        }
194    }
195
196    /// Create a new executor with tools, approval gate, and LLM provider.
197    /// With a provider, prompt-based steps will execute actual LLM inference.
198    pub fn with_provider(
199        tools: Arc<HashMap<String, Arc<dyn Tool>>>,
200        interaction_gate: Arc<dyn InteractionGate>,
201        provider: Arc<dyn LlmProvider>,
202    ) -> Self {
203        Self {
204            tools: Arc::new(tokio::sync::RwLock::new((*tools).clone())),
205            interaction_gate,
206            provider: Some(provider),
207        }
208    }
209
210    /// Set the LLM provider for prompt-based steps.
211    /// When set, steps with a `prompt` field but no `tool` will call
212    /// the provider for actual inference instead of returning raw text.
213    pub fn set_provider(&mut self, provider: Arc<dyn LlmProvider>) {
214        self.provider = Some(provider);
215    }
216
217    /// Register a tool for workflow step execution.
218    pub async fn register_tool(&self, tool: Arc<dyn Tool>) {
219        self.tools.write().await.insert(tool.name().to_string(), tool);
220    }
221
222    /// Get a handle to the internal tool registry (Arc<RwLock<HashMap>>).
223    /// Used by StateGraphExecutor to share the same tool registry.
224    pub fn tools_handle(&self) -> Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>> {
225        self.tools.clone()
226    }
227
228    /// Execute a workflow DAG.
229    ///
230    /// Runs the DAG level by level, with steps at the same level
231    /// executing concurrently. Returns a WorkflowResult with the
232    /// status of each step.
233    pub async fn execute(
234        &self,
235        dag: &WorkflowDag,
236        config: &WorkflowConfig,
237    ) -> Result<WorkflowResult> {
238        let start_time = std::time::Instant::now();
239        let mut step_results: HashMap<String, StepResult> = HashMap::new();
240        let mut context = WorkflowContext::from_config(config);
241        let mut completed_levels = 0;
242
243        // Mark all steps as pending initially
244        for (id, _) in &dag.nodes {
245            step_results.insert(id.clone(), StepResult {
246                step_id: id.clone(),
247                status: StepStatus::Pending,
248                output: None,
249                error: None,
250                retries_used: 0,
251                execution_time_ms: None,
252            });
253        }
254
255        // Execute each level
256        for level_ids in &dag.levels {
257            let _level_start = std::time::Instant::now();
258
259            // Check if any dependencies failed — skip if not continue_on_failure
260            let mut skip_level = false;
261            if !config.continue_on_failure {
262                for step_id in level_ids {
263                    if let Some(node) = dag.get_node(step_id) {
264                        for dep_id in &node.depends_on {
265                            if let Some(dep_result) = step_results.get(dep_id) {
266                                if dep_result.status == StepStatus::Failed || dep_result.status == StepStatus::Skipped {
267                                    skip_level = true;
268                                    break;
269                                }
270                            }
271                        }
272                    }
273                    if skip_level {
274                        break;
275                    }
276                }
277            }
278
279            if skip_level {
280                // Skip all steps in this level
281                for step_id in level_ids {
282                    if let Some(result) = step_results.get_mut(step_id) {
283                        result.status = StepStatus::Skipped;
284                        result.error = Some("Dependency failed — step skipped".to_string());
285                    }
286                }
287                continue;
288            }
289
290            // Execute all steps in this level concurrently
291            let mut level_futures = Vec::new();
292
293            for step_id in level_ids {
294                let node = dag.get_node(step_id).unwrap();
295                let step_config = node.step.clone();
296                let retry_policy = config.effective_retry_policy(step_id);
297                let timeout_secs = config.effective_timeout(step_id);
298                let tools = self.tools.clone();
299                let interaction_gate = self.interaction_gate.clone();
300                let context_snapshot = context.clone();
301                let provider = self.provider.clone();
302
303                level_futures.push(tokio::spawn(async move {
304                    execute_step(
305                        step_config,
306                        retry_policy,
307                        timeout_secs,
308                        tools,
309                        interaction_gate,
310                        context_snapshot,
311                        provider,
312                    ).await
313                }));
314            }
315
316            // Collect results from all steps in this level
317            for future in level_futures {
318                let result = future.await.map_err(|e| {
319                    OneAIError::Workflow(format!("Task join error: {}", e))
320                })?;
321
322                match result {
323                    Ok(step_result) => {
324                        step_results.insert(step_result.step_id.clone(), step_result.clone());
325                        if step_result.status == StepStatus::Completed {
326                            if let Some(output) = &step_result.output {
327                                context.set_step_output(&step_result.step_id, output);
328                            }
329                        }
330                    }
331                    Err(e) => {
332                        // Step execution error
333                        tracing::error!("Step execution error: {}", e);
334                    }
335                }
336            }
337
338            completed_levels += 1;
339        }
340
341        let total_time_ms = start_time.elapsed().as_millis() as u64;
342
343        let success = step_results.values()
344            .all(|r| r.status == StepStatus::Completed || r.status == StepStatus::Skipped);
345
346        Ok(WorkflowResult {
347            name: dag.name.clone(),
348            step_results,
349            variables: context.variables,
350            success,
351            total_time_ms,
352            completed_levels,
353        })
354    }
355}
356
357/// Execute a single workflow step with retry support and template interpolation.
358///
359/// Template interpolation replaces `{{step_id_output}}` and `{{step_id}}` with
360/// previous step outputs, and `{{variable_name}}` with context variables.
361/// This is applied to `step.tool_args` (for tool steps) and `step.prompt`
362/// (for prompt-based steps) before execution.
363///
364/// When a provider is available, prompt-based steps (no tool, just prompt)
365/// execute actual LLM inference. When no provider is set, they return
366/// the interpolated prompt text.
367async fn execute_step(
368    step: crate::config::StepConfig,
369    retry_policy: RetryPolicy,
370    timeout_secs: Option<u64>,
371    tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
372    interaction_gate: Arc<dyn InteractionGate>,
373    context: WorkflowContext,
374    provider: Option<Arc<dyn LlmProvider>>,
375) -> Result<StepResult> {
376    let start_time = std::time::Instant::now();
377    let step_id = step.id.clone();
378    let mut retries_used = 0;
379    let mut last_error: Option<String> = None;
380
381    // ─── Template interpolation ──────────────────────────────────────────
382    // Interpolate {{variable}} patterns in step.tool_args and step.prompt
383    // using context.step_outputs and context.variables.
384    let interpolated_prompt = step.prompt.as_ref()
385        .map(|p| interpolate_template(p, &context));
386    let interpolated_tool_args = step.tool_args.as_ref()
387        .map(|a| {
388            // Interpolate string values in the JSON
389            let json_str = serde_json::to_string(a).unwrap_or_default();
390            let interpolated = interpolate_template(&json_str, &context);
391            serde_json::from_str(&interpolated).unwrap_or_else(|_| a.clone())
392        });
393
394    // If this step requires approval, request it
395    if step.requires_approval {
396        let approval_request = oneai_core::ApprovalRequest {
397            tool_name: step.id.clone(),
398            args: step.tool_args.clone().unwrap_or(serde_json::json!({})),
399            risk_level: oneai_core::RiskLevel::High,
400            permission_level: Some(oneai_core::PermissionLevel::Full),
401            justification: format!("Workflow step '{}' requires human approval", step.id),
402        };
403
404        let response = interaction_gate
405            .request(oneai_core::InteractionRequest::ToolApproval {
406                approval: approval_request,
407            })
408            .await?;
409        match response {
410            oneai_core::InteractionResponse::Abort { reason } => {
411                return Ok(StepResult {
412                    step_id,
413                    status: StepStatus::Failed,
414                    output: None,
415                    error: Some(format!("Approval denied: {}", reason)),
416                    retries_used: 0,
417                    execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
418                });
419            }
420            oneai_core::InteractionResponse::Revise { feedback } => {
421                // Stateless workflow path can't loop on feedback → treat as denied.
422                return Ok(StepResult {
423                    step_id,
424                    status: StepStatus::Failed,
425                    output: None,
426                    error: Some(format!("Approval denied: {}", feedback)),
427                    retries_used: 0,
428                    execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
429                });
430            }
431            oneai_core::InteractionResponse::ProceedWith {
432                modification: oneai_core::InteractionModification::ReplaceToolArgs(_args),
433            } => {
434                // Tool args were rewritten by the approver. (In a real
435                // implementation we'd merge _args into tool_args before exec.)
436            }
437            oneai_core::InteractionResponse::ProceedWith { .. }
438            | oneai_core::InteractionResponse::Proceed
439            | oneai_core::InteractionResponse::Choose { .. } => {
440                // Approved as-is (Choose is PlanDecision-only and doesn't apply).
441            }
442            // InteractionResponse is #[non_exhaustive]; unknown variants proceed.
443            _ => {}
444        }
445    }
446
447    // Execute with retry loop
448    for attempt in 0..=retry_policy.max_retries {
449        let exec_result: std::result::Result<oneai_core::ToolOutput, OneAIError> = if let Some(tool_name) = &step.tool {
450            // Tool-based step — read from RwLock
451            let tools_map = tools.read().await;
452            let tool = tools_map.get(tool_name);
453            if let Some(tool) = tool {
454                // Use interpolated args if available, otherwise use original args
455                let args = match &interpolated_tool_args {
456                    Some(a) => a.clone(),
457                    None => step.tool_args.clone().unwrap_or(serde_json::json!({})),
458                };                let timeout = timeout_secs.unwrap_or(60);
459
460                let timeout_result = tokio::time::timeout(
461                    std::time::Duration::from_secs(timeout),
462                    tool.execute(args.clone()),
463                ).await;
464
465                match timeout_result {
466                    Ok(inner_result) => inner_result, // Result<ToolOutput, OneAIError>
467                    Err(_) => Err(OneAIError::Timeout(format!(
468                        "Step '{}' timed out after {} seconds", step_id, timeout
469                    ))),
470                }
471            } else {
472                Err(OneAIError::Workflow(format!(
473                    "Tool '{}' not found for step '{}'",
474                    tool_name, step_id
475                )))
476            }
477        } else if let Some(provider) = &provider {
478            // Prompt-based step with LLM provider — execute actual inference
479            let prompt_text = match &interpolated_prompt {
480                Some(p) => p.clone(),
481                None => step.prompt.clone().unwrap_or_default(),
482            };
483            if prompt_text.is_empty() {
484                Ok(oneai_core::ToolOutput {
485                    success: true,
486                    content: String::new(),
487                    error: None,
488                })
489            } else {
490                // Build a minimal inference request with the interpolated prompt
491                let mut conversation = Conversation::new();
492                conversation.add_message(Message::system(
493                    "You are executing a step in a deterministic workflow. Respond concisely."
494                ));
495                conversation.add_message(Message::user(prompt_text));
496
497                let request = InferenceRequest {
498                    conversation,
499                    tools: vec![], // No tools for prompt steps
500                    max_tokens: Some(2048),
501                    temperature: Some(0.3),
502                    top_p: None,
503                    stop_sequences: vec![],
504                    constrained_output: None,
505                    thinking_budget: None,
506                    metadata: HashMap::new(),
507                };
508
509                let response = provider.infer(request).await;
510                match response {
511                    Ok(inference_response) => {
512                        Ok(oneai_core::ToolOutput {
513                            success: true,
514                            content: inference_response.message.text_content(),
515                            error: None,
516                        })
517                    }
518                    Err(e) => Err(OneAIError::Provider(format!(
519                        "LLM inference failed for step '{}': {}", step_id, e
520                    ))),
521                }
522            }
523        } else {
524            // No tool defined and no provider — just return interpolated prompt
525            let prompt_text = match &interpolated_prompt {
526                Some(p) => p.clone(),
527                None => step.prompt.clone().unwrap_or_default(),
528            };
529            Ok(oneai_core::ToolOutput {
530                success: true,
531                content: prompt_text,
532                error: None,
533            })
534        };
535
536        match exec_result {
537            Ok(output) => {
538                if output.success {
539                    return Ok(StepResult {
540                        step_id,
541                        status: StepStatus::Completed,
542                        output: Some(output.content),
543                        error: None,
544                        retries_used,
545                        execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
546                    });
547                } else {
548                    last_error = Some(output.error.unwrap_or_else(|| "Tool execution failed".to_string()));
549                }
550            }
551            Err(e) => {
552                last_error = Some(format!("Step error: {}", e));
553            }
554        }
555
556        retries_used = attempt;
557
558        if attempt < retry_policy.max_retries {
559            // Wait before retry
560            tokio::time::sleep(
561                std::time::Duration::from_secs(retry_policy.retry_delay_secs)
562            ).await;
563        }
564    }
565
566    // All retries exhausted
567    Ok(StepResult {
568        step_id,
569        status: StepStatus::Failed,
570        output: None,
571        error: last_error,
572        retries_used,
573        execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
574    })
575}
576
577// ─── Template Interpolation ────────────────────────────────────────────────
578
579/// Interpolate `{{variable}}` template patterns in a string.
580///
581/// Replaces `{{key}}` patterns using two sources:
582/// 1. `context.step_outputs` — previous step results. Both `{{step_id}}`
583///    and `{{step_id_output}}` resolve to the output of step `step_id`.
584/// 2. `context.variables` — shared workflow variables.
585///
586/// **Order**: Longer patterns are replaced first to avoid substring conflicts.
587/// `{{step_id_output}}` is replaced before `{{step_id}}`.
588///
589/// Example:
590/// ```
591/// let template = "Review {{read_diff_output}} with focus on {{focus_area}}";
592/// // If read_diff has output and focus_area = "security":
593/// // → "Review <output> with focus on security"
594/// ```
595pub fn interpolate_template(template: &str, context: &WorkflowContext) -> String {
596    let mut result = template.to_string();
597
598    // First pass: replace longer patterns (step_id_output) before shorter ones (step_id)
599    // This prevents {{step1_output}} from being partially matched by {{step1}}
600    for (step_id, output) in &context.step_outputs {
601        // Long form: {{step_id_output}} — must be replaced first
602        // Build pattern manually: "{{" + step_id + "_output}}"
603        let long_pattern = "{{".to_string() + step_id + "_output}}";
604        result = result.replace(&long_pattern, output);
605    }
606    for (step_id, output) in &context.step_outputs {
607        // Short form: {{step_id}} — replaced second
608        // Build pattern manually: "{{" + step_id + "}}"
609        let short_pattern = "{{".to_string() + step_id + "}}";
610        result = result.replace(&short_pattern, output);
611    }
612
613    // Replace variable references: {{variable_name}}
614    for (key, value) in &context.variables {
615        result = result.replace(&format!("{{{{{}}}}}", key), value);
616    }
617
618    result
619}
620
621#[cfg(test)]
622mod interpolation_tests {
623    use super::*;
624
625    #[test]
626    fn test_interpolate_step_outputs() {
627        let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
628        context.set_step_output("step1", "result of step1");
629        context.set_step_output("step2", "result of step2");
630
631        // Short form {{step_id}}
632        let result = interpolate_template("{{step1}} then {{step2}}", &context);
633        assert_eq!(result, "result of step1 then result of step2");
634
635        // Long form {{step_id_output}}
636        let result = interpolate_template("{{step1_output}} and {{step2_output}}", &context);
637        assert_eq!(result, "result of step1 and result of step2");
638    }
639
640    #[test]
641    fn test_interpolate_variables() {
642        let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
643        context.set_variable("focus_area", "security");
644        context.set_variable("language", "Rust");
645
646        let result = interpolate_template("Focus on {{focus_area}} in {{language}}", &context);
647        assert_eq!(result, "Focus on security in Rust");
648    }
649
650    #[test]
651    fn test_interpolate_mixed() {
652        let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
653        context.set_step_output("read_diff", "auth.rs changes");
654        context.set_variable("focus", "security");
655
656        let result = interpolate_template("Review {{read_diff_output}} with focus on {{focus}}", &context);
657        assert_eq!(result, "Review auth.rs changes with focus on security");
658    }
659
660    #[test]
661    fn test_interpolate_no_match() {
662        let context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
663        let result = interpolate_template("No variables here {{unknown}}", &context);
664        assert_eq!(result, "No variables here {{unknown}}");
665    }
666}