Skip to main content

oxicode_sdk/
workflow_engine.rs

1//! Workflow execution engine.
2//!
3//! Takes a parsed [`WorkflowDefinition`] and executes its steps using the
4//! SDK's coordination primitives ([`SharedMemory`], [`Consensus`]) and a
5//! pre-built agent map. The engine is pure orchestration — it does not
6//! spawn agents itself; the caller registers them by name at construction
7//! time via [`WorkflowEngine::new`].
8//!
9//! # Step semantics
10//!
11//! Every top-level step produces exactly one [`StepOutput`]. Nested step
12//! lists (currently only [`WorkflowStepDef::Chain`]) do **not** emit their
13//! own `StepOutput`s — their children run internally and the chain emits a
14//! single summary row. This keeps the workflow result flat: one row per
15//! declared top-level step.
16//!
17//! # `{previous}` substitution
18//!
19//! The task string of a [`WorkflowStepDef::Run`] step may contain the
20//! literal `{previous}`; the engine replaces it with the most recent
21//! `Run`/`Chain`/`Parallel` step's response text. Inside a `Chain`,
22//! `{previous}` threads through the chain's child steps independently of
23//! the outer workflow.
24
25use std::collections::HashMap;
26use std::sync::Arc;
27
28use crate::coordination::consensus::Consensus;
29use crate::coordination::shared_memory::{MemoryKey, SharedMemory};
30use crate::lifecycle::AgentHandle;
31use crate::workflow_dsl::{WorkflowDefinition, WorkflowStepDef};
32
33use serde_json::Value;
34
35/// Output of one workflow step.
36#[derive(Debug, Clone)]
37pub struct StepOutput {
38    /// Position of this step in the top-level `steps` list (0-indexed).
39    pub index: usize,
40    /// Variant name (`"Run"`, `"Parallel"`, `"Chain"`, `"ForEach"`,
41    /// `"Vote"`, `"SetState"`).
42    pub variant: String,
43    /// Human-readable one-line summary of what happened.
44    pub summary: String,
45    /// Wall-clock duration in milliseconds.
46    pub duration_ms: u128,
47    /// Whether the step succeeded.
48    pub success: bool,
49    /// Error message when `success` is false.
50    pub error: Option<String>,
51}
52
53impl StepOutput {
54    /// Construct a step output (duration/index filled in by the engine).
55    pub fn new(
56        variant: impl Into<String>,
57        summary: impl Into<String>,
58        error: Option<String>,
59        success: bool,
60    ) -> Self {
61        Self {
62            index: 0,
63            variant: variant.into(),
64            summary: summary.into(),
65            duration_ms: 0,
66            success,
67            error,
68        }
69    }
70}
71
72/// Aggregate result of a workflow run.
73#[derive(Debug, Clone)]
74pub struct WorkflowResult {
75    /// Workflow name (echoed from [`WorkflowDefinition::name`]).
76    pub name: String,
77    /// One row per top-level step that ran. Stops at the first failing
78    /// step — remaining steps are not executed.
79    pub step_outputs: Vec<StepOutput>,
80    /// Total wall-clock duration in milliseconds.
81    pub total_duration_ms: u128,
82    /// True iff every step that ran succeeded.
83    pub success: bool,
84}
85
86/// Workflow execution engine with a pre-built agent map.
87///
88/// Agents are keyed by the names used in [`WorkflowStepDef`] variants
89/// (e.g. the `agent` field of `Run`). The engine looks them up by name
90/// at execution time; an unknown agent name fails the step with a clear
91/// error rather than panicking.
92pub struct WorkflowEngine {
93    agents: HashMap<String, AgentHandle>,
94    shared_memory: Arc<SharedMemory>,
95    consensus: Arc<Consensus>,
96}
97
98impl WorkflowEngine {
99    /// Create a new engine with the given agent map and fresh
100    /// `SharedMemory` / `Consensus` instances.
101    pub fn new(agents: HashMap<String, AgentHandle>) -> Self {
102        Self {
103            agents,
104            shared_memory: Arc::new(SharedMemory::new()),
105            consensus: Arc::new(Consensus::new()),
106        }
107    }
108
109    /// Replace the default `SharedMemory` with a caller-supplied one
110    /// (e.g. to share state across multiple workflow runs).
111    pub fn with_memory(mut self, memory: Arc<SharedMemory>) -> Self {
112        self.shared_memory = memory;
113        self
114    }
115
116    /// Replace the default `Consensus` with a caller-supplied one.
117    pub fn with_consensus(mut self, consensus: Arc<Consensus>) -> Self {
118        self.consensus = consensus;
119        self
120    }
121
122    /// Borrow the shared memory (for tests / inspection).
123    pub fn shared_memory(&self) -> &SharedMemory {
124        &self.shared_memory
125    }
126
127    /// Borrow the consensus (for tests / inspection).
128    pub fn consensus(&self) -> &Consensus {
129        &self.consensus
130    }
131
132    /// Execute the workflow top-to-bottom. Stops at the first failing
133    /// step (subsequent steps are not run).
134    pub async fn execute(&self, workflow: &WorkflowDefinition) -> WorkflowResult {
135        let start = std::time::Instant::now();
136        let mut step_outputs = Vec::with_capacity(workflow.steps.len());
137        let mut success = true;
138        let mut last_output: Option<String> = None;
139
140        for (i, step) in workflow.steps.iter().enumerate() {
141            let step_start = std::time::Instant::now();
142            let (mut output, new_last) = self.run_step(step, last_output.as_deref()).await;
143            output.index = i;
144            output.duration_ms = step_start.elapsed().as_millis();
145            let ok = output.success;
146            step_outputs.push(output);
147            if new_last.is_some() {
148                last_output = new_last;
149            }
150            if !ok {
151                success = false;
152                break;
153            }
154        }
155
156        WorkflowResult {
157            name: workflow.name.clone(),
158            step_outputs,
159            total_duration_ms: start.elapsed().as_millis(),
160            success,
161        }
162    }
163
164    /// Run one step, returning `(output, optional_response_text)`.
165    ///
166    /// The optional response text is the step's "result text" used for
167    /// `{previous}` substitution in subsequent steps. Only `Run`,
168    /// `Parallel`, and `Chain` produce one.
169    ///
170    /// `previous` is the prior step's response text (or `None` for the
171    /// first step / after a step that doesn't produce text).
172    async fn run_step(
173        &self,
174        step: &WorkflowStepDef,
175        previous: Option<&str>,
176    ) -> (StepOutput, Option<String>) {
177        match step {
178            WorkflowStepDef::Run {
179                agent,
180                task,
181                output,
182            } => {
183                let task = substitute_previous(task, previous);
184                let Some(handle) = self.agents.get(agent) else {
185                    return (
186                        StepOutput::new(
187                            "Run",
188                            format!("Unknown agent '{agent}'"),
189                            Some(format!("agent not found: {agent}")),
190                            false,
191                        ),
192                        None,
193                    );
194                };
195                match handle.run(task).await {
196                    Ok((response, _)) => {
197                        let text = response.content;
198                        if let Some(key) = output {
199                            let mk = MemoryKey::new("workflow", key);
200                            let _ = self.shared_memory.write(
201                                &mk,
202                                serde_json::json!(&text),
203                                "engine",
204                                None,
205                            );
206                        }
207                        (
208                            StepOutput::new(
209                                "Run",
210                                format!(
211                                    "agent '{agent}' responded ({} chars)",
212                                    text.chars().count()
213                                ),
214                                None,
215                                true,
216                            ),
217                            Some(text),
218                        )
219                    }
220                    Err(e) => (
221                        StepOutput::new(
222                            "Run",
223                            format!("agent '{agent}' failed"),
224                            Some(e.to_string()),
225                            false,
226                        ),
227                        None,
228                    ),
229                }
230            }
231
232            WorkflowStepDef::Parallel {
233                agents,
234                task,
235                concurrency: _,
236            } => {
237                // Look up every named agent up front; abort on first miss.
238                let mut handles = Vec::new();
239                for name in agents {
240                    match self.agents.get(name) {
241                        Some(h) => handles.push(h.clone()),
242                        None => {
243                            return (
244                                StepOutput::new(
245                                    "Parallel",
246                                    format!("Unknown agent '{name}'"),
247                                    Some(format!("agent not found: {name}")),
248                                    false,
249                                ),
250                                None,
251                            );
252                        }
253                    }
254                }
255                let task = substitute_previous(task, previous);
256
257                // Spawn one tokio task per agent and run them concurrently.
258                // Concurrency cap is currently advisory (no semaphore) —
259                // every agent runs at once. Adding a semaphore is a
260                // follow-up if real-world fan-out grows.
261                let mut join_handles = Vec::with_capacity(handles.len());
262                for h in handles {
263                    let task = task.clone();
264                    join_handles.push(tokio::spawn(async move { h.run(task).await }));
265                }
266
267                let mut responses: Vec<String> = Vec::new();
268                let mut failures: Vec<String> = Vec::new();
269                for jh in join_handles {
270                    match jh.await {
271                        Ok(Ok((r, _))) => responses.push(r.content),
272                        Ok(Err(e)) => failures.push(e.to_string()),
273                        Err(e) => failures.push(format!("join error: {e}")),
274                    }
275                }
276
277                let ok = failures.is_empty();
278                let err = if failures.is_empty() {
279                    None
280                } else {
281                    Some(failures.join("; "))
282                };
283                // The "previous" text after a Parallel is the last
284                // successful response (mirrors Chain's behavior).
285                let summary_text = responses.last().cloned();
286                let summary = format!(
287                    "Parallel: {}/{} agents succeeded",
288                    responses.len(),
289                    agents.len()
290                );
291                (StepOutput::new("Parallel", summary, err, ok), summary_text)
292            }
293
294            WorkflowStepDef::Chain { steps } => {
295                // Recursively run nested steps, threading `{previous}`
296                // through each child. The chain itself emits one summary
297                // row; child outputs are folded into the summary.
298                let mut last: Option<String> = previous.map(str::to_string);
299                let mut chain_ok = true;
300                let mut chain_err: Option<String> = None;
301                let mut last_child_summary = String::new();
302                let mut ran = 0usize;
303                let total = steps.len();
304
305                for child in steps {
306                    let (out, new_last) = Box::pin(self.run_step(child, last.as_deref())).await;
307                    ran += 1;
308                    last = new_last;
309                    last_child_summary = out.summary;
310                    if !out.success {
311                        chain_ok = false;
312                        chain_err = out.error;
313                        break;
314                    }
315                }
316
317                let summary = if chain_ok {
318                    format!("chain of {total} steps ok — last: {last_child_summary}")
319                } else {
320                    format!("chain failed at step {ran}/{total}: {last_child_summary}")
321                };
322
323                (StepOutput::new("Chain", summary, chain_err, chain_ok), last)
324            }
325
326            WorkflowStepDef::ForEach {
327                items_key,
328                namespace,
329                agent,
330                task_template,
331                concurrency: _,
332            } => {
333                let ns = namespace.as_deref().unwrap_or("workflow");
334                let mk = MemoryKey::new(ns, items_key);
335
336                let items = match self.shared_memory.read(&mk) {
337                    Some(v) => match v.as_array() {
338                        Some(a) => a.clone(),
339                        None => {
340                            return (
341                                StepOutput::new(
342                                    "ForEach",
343                                    format!("ForEach: value at {ns}/{items_key} is not an array"),
344                                    Some(format!("items at {ns}/{items_key} is not a JSON array")),
345                                    false,
346                                ),
347                                None,
348                            );
349                        }
350                    },
351                    None => {
352                        return (
353                            StepOutput::new(
354                                "ForEach",
355                                format!("ForEach: key {ns}/{items_key} not found"),
356                                Some(format!(
357                                    "items key {ns}/{items_key} not found in SharedMemory"
358                                )),
359                                false,
360                            ),
361                            None,
362                        );
363                    }
364                };
365
366                let Some(handle) = self.agents.get(agent) else {
367                    return (
368                        StepOutput::new(
369                            "ForEach",
370                            format!("ForEach: unknown agent '{agent}'"),
371                            Some(format!("agent not found: {agent}")),
372                            false,
373                        ),
374                        None,
375                    );
376                };
377
378                let total = items.len();
379                let mut succeeded = 0usize;
380                let mut last_err: Option<String> = None;
381                let mut last_text: Option<String> = None;
382
383                // Sequential execution. The `concurrency` field is
384                // accepted by the DSL but ignored here; parallel fan-out
385                // for ForEach is a follow-up.
386                for (i, item) in items.iter().enumerate() {
387                    let item_str = match item {
388                        Value::String(s) => s.clone(),
389                        other => other.to_string(),
390                    };
391                    let task = task_template.replace("{item}", &item_str);
392                    match handle.run(task).await {
393                        Ok((r, _)) => {
394                            succeeded += 1;
395                            last_text = Some(r.content);
396                        }
397                        Err(e) => {
398                            last_err = Some(format!("item {i} ({item_str}): {e}"));
399                            break;
400                        }
401                    }
402                }
403
404                let ok = last_err.is_none();
405                let summary = format!("ForEach: {succeeded}/{total} items succeeded on '{agent}'");
406                (StepOutput::new("ForEach", summary, last_err, ok), last_text)
407            }
408
409            WorkflowStepDef::Vote {
410                agents,
411                question,
412                threshold,
413            } => {
414                let sid = format!("wf-vote-{}-{}", std::process::id(), question.len());
415                let thr = threshold.unwrap_or(0.5);
416                self.consensus.start(&sid, agents.clone(), thr);
417
418                let mut missing: Vec<String> = Vec::new();
419                let mut first_decision: Option<String> = None;
420
421                for voter in agents {
422                    let Some(handle) = self.agents.get(voter) else {
423                        missing.push(voter.clone());
424                        continue;
425                    };
426                    let task = question.clone();
427                    match handle.run(task).await {
428                        Ok((r, _)) => {
429                            let value = r.content.trim().to_string();
430                            if let Ok(vr) = self.consensus.vote(&sid, voter, value)
431                                && vr.decided
432                                && first_decision.is_none()
433                            {
434                                first_decision = vr.decision.clone();
435                            }
436                        }
437                        Err(e) => {
438                            // A voter failing to respond counts as
439                            // abstention; surface it in tracing but
440                            // don't abort the vote.
441                            tracing::debug!(
442                                voter = %voter,
443                                error = %e,
444                                "Vote: agent failed to respond"
445                            );
446                        }
447                    }
448                }
449
450                let final_result = self.consensus.status(&sid);
451                let (decided, summary) = match final_result {
452                    Some(vr) if vr.decided => (
453                        true,
454                        format!(
455                            "Vote: decided = {:?} ({} of {} votes)",
456                            vr.decision, vr.votes_received, vr.total_voters
457                        ),
458                    ),
459                    Some(vr) => (
460                        false,
461                        format!(
462                            "Vote: no consensus ({}/{} votes cast)",
463                            vr.votes_received, vr.total_voters
464                        ),
465                    ),
466                    None => (false, "Vote: session not found".to_string()),
467                };
468
469                let err = if !missing.is_empty() {
470                    Some(format!("missing voters: {}", missing.join(", ")))
471                } else if !decided {
472                    Some("no consensus reached".to_string())
473                } else {
474                    None
475                };
476
477                let ok = missing.is_empty() && decided;
478                (StepOutput::new("Vote", summary, err, ok), first_decision)
479            }
480
481            WorkflowStepDef::SetState {
482                key,
483                value,
484                namespace,
485            } => {
486                let n = namespace.as_deref().unwrap_or("workflow");
487                let mk = MemoryKey::new(n, key);
488                match self.shared_memory.write(&mk, value.clone(), "engine", None) {
489                    Ok(_) => (
490                        StepOutput::new("SetState", format!("{n}/{key} = {value}"), None, true),
491                        None,
492                    ),
493                    Err(e) => (
494                        StepOutput::new(
495                            "SetState",
496                            format!("{n}/{key} failed"),
497                            Some(e.to_string()),
498                            false,
499                        ),
500                        None,
501                    ),
502                }
503            }
504        }
505    }
506}
507
508/// Replace `{previous}` in `text` with the prior step's response text,
509/// if any. When `previous` is `None` the literal is left untouched —
510/// callers using `{previous}` in the first step will see the placeholder
511/// in the prompt, which makes the misuse visible.
512fn substitute_previous(text: &str, previous: Option<&str>) -> String {
513    match previous {
514        Some(p) => text.replace("{previous}", p),
515        None => text.to_string(),
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[tokio::test]
524    async fn set_state_workflow() {
525        let yaml = "---\nname: test\nsteps:\n  - type: set_state\n    key: m\n    value: 42\n";
526        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
527        let engine = WorkflowEngine::new(HashMap::new());
528        let result = engine.execute(&wf).await;
529        assert!(result.success);
530        assert_eq!(result.step_outputs.len(), 1);
531        assert_eq!(result.step_outputs[0].variant, "SetState");
532    }
533
534    #[tokio::test]
535    async fn unknown_agent_fails_run() {
536        let yaml = "---\nname: t\nsteps:\n  - type: run\n    agent: ghost\n    task: h\n";
537        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
538        let engine = WorkflowEngine::new(HashMap::new());
539        let result = engine.execute(&wf).await;
540        assert!(!result.success);
541    }
542
543    #[tokio::test]
544    async fn short_circuits_on_failure() {
545        let yaml = "---\nname: t\nsteps:\n  - type: run\n    agent: ghost\n    task: x\n  - type: set_state\n    key: n\n    value: 1\n";
546        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
547        let engine = WorkflowEngine::new(HashMap::new());
548        let result = engine.execute(&wf).await;
549        assert!(!result.success);
550        assert_eq!(result.step_outputs.len(), 1);
551    }
552
553    #[tokio::test]
554    async fn chain_runs_nested_steps_and_threads_previous() {
555        // A chain with two set_state children. No agent required —
556        // SetState is pure SharedMemory. We assert both children ran
557        // (two keys written) and the chain emits a single summary row.
558        let yaml = "---\nname: c\nsteps:\n  - type: chain\n    steps:\n      - type: set_state\n        key: a\n        value: 1\n      - type: set_state\n        key: b\n        value: 2\n";
559        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
560        let engine = WorkflowEngine::new(HashMap::new());
561        let result = engine.execute(&wf).await;
562        assert!(
563            result.success,
564            "chain should succeed: {:?}",
565            result.step_outputs
566        );
567        assert_eq!(result.step_outputs.len(), 1, "chain emits one summary row");
568        assert_eq!(result.step_outputs[0].variant, "Chain");
569        let mk_a = MemoryKey::new("workflow", "a");
570        let mk_b = MemoryKey::new("workflow", "b");
571        assert_eq!(
572            engine.shared_memory().read(&mk_a),
573            Some(serde_json::json!(1))
574        );
575        assert_eq!(
576            engine.shared_memory().read(&mk_b),
577            Some(serde_json::json!(2))
578        );
579    }
580
581    #[tokio::test]
582    async fn chain_propagates_failure_and_short_circuits() {
583        // chain of [set_state, run-unknown-agent, set_state-after].
584        // Second child fails → third must NOT run.
585        let yaml = "---\nname: c\nsteps:\n  - type: chain\n    steps:\n      - type: set_state\n        key: pre\n        value: 1\n      - type: run\n        agent: ghost\n        task: x\n      - type: set_state\n        key: post\n        value: 2\n";
586        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
587        let engine = WorkflowEngine::new(HashMap::new());
588        let result = engine.execute(&wf).await;
589        assert!(!result.success, "chain should fail");
590        let mk_pre = MemoryKey::new("workflow", "pre");
591        let mk_post = MemoryKey::new("workflow", "post");
592        assert_eq!(
593            engine.shared_memory().read(&mk_pre),
594            Some(serde_json::json!(1))
595        );
596        assert_eq!(engine.shared_memory().read(&mk_post), None);
597    }
598
599    #[tokio::test]
600    async fn foreach_rejects_missing_items_key() {
601        let yaml = "---\nname: fe\nsteps:\n  - type: for_each\n    items_key: nope\n    agent: a\n    task_template: \"x {item}\"\n";
602        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
603        let engine = WorkflowEngine::new(HashMap::new());
604        let result = engine.execute(&wf).await;
605        assert!(!result.success);
606        assert!(
607            result.step_outputs[0]
608                .error
609                .as_deref()
610                .unwrap_or("")
611                .contains("not found")
612        );
613    }
614
615    #[tokio::test]
616    async fn foreach_rejects_non_array_items() {
617        let engine = WorkflowEngine::new(HashMap::new());
618        let mk = MemoryKey::new("workflow", "items");
619        engine
620            .shared_memory()
621            .write(&mk, serde_json::json!("not an array"), "test", None)
622            .unwrap();
623        let yaml = "---\nname: fe\nsteps:\n  - type: for_each\n    items_key: items\n    agent: a\n    task_template: \"x {item}\"\n";
624        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
625        let result = engine.execute(&wf).await;
626        assert!(!result.success);
627        assert!(
628            result.step_outputs[0]
629                .error
630                .as_deref()
631                .unwrap_or("")
632                .contains("not a JSON array")
633        );
634    }
635
636    #[tokio::test]
637    async fn foreach_rejects_unknown_agent() {
638        let engine = WorkflowEngine::new(HashMap::new());
639        let mk = MemoryKey::new("workflow", "items");
640        engine
641            .shared_memory()
642            .write(&mk, serde_json::json!(["a", "b"]), "test", None)
643            .unwrap();
644        let yaml = "---\nname: fe\nsteps:\n  - type: for_each\n    items_key: items\n    agent: ghost\n    task_template: \"x {item}\"\n";
645        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
646        let result = engine.execute(&wf).await;
647        assert!(!result.success);
648        assert!(
649            result.step_outputs[0]
650                .error
651                .as_deref()
652                .unwrap_or("")
653                .contains("agent not found")
654        );
655    }
656
657    #[tokio::test]
658    async fn vote_with_unknown_voter_fails() {
659        // Vote step references a voter that isn't registered in the
660        // engine's agent map → step must fail with "missing voters".
661        let yaml = "---\nname: v\nsteps:\n  - type: vote\n    agents: [ghost]\n    question: yes or no\n    threshold: 0.5\n";
662        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
663        let engine = WorkflowEngine::new(HashMap::new());
664        let result = engine.execute(&wf).await;
665        assert!(!result.success);
666        assert_eq!(result.step_outputs[0].variant, "Vote");
667        assert!(
668            result.step_outputs[0]
669                .error
670                .as_deref()
671                .unwrap_or("")
672                .contains("missing voters")
673        );
674    }
675}