Skip to main content

oxicode_sdk/
workflow_dsl.rs

1//! Declarative workflow DSL — YAML definitions that map to existing coordination APIs.
2//!
3//! WorkflowDefinition parses a YAML file describing a multi-step workflow.
4//! The execution plan maps each step to the appropriate coordination module call:
5//! - Parallel → AgentGroup::parallel()
6//! - Chain → AgentGroup::sequential()
7//! - ForEach → CoordinatedGroup::map_reduce()
8//! - Vote → Consensus::start() + cast_vote()
9//! - SetState → SharedMemory::write()
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// A complete workflow definition.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct WorkflowDefinition {
18    /// Workflow name
19    pub name: String,
20    /// Human-readable description
21    #[serde(default)]
22    pub description: String,
23    /// Ordered list of steps
24    pub steps: Vec<WorkflowStepDef>,
25}
26
27/// A single step in a workflow.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum WorkflowStepDef {
31    /// Run a single agent with a task.
32    Run {
33        /// Name of the agent to run.
34        agent: String,
35        /// Task prompt to send to the agent.
36        task: String,
37        /// Optional SharedMemory key to store the agent's result under.
38        #[serde(default)]
39        output: Option<String>,
40    },
41    /// Run multiple agents in parallel with the same task.
42    Parallel {
43        /// Names of the agents to run concurrently.
44        agents: Vec<String>,
45        /// Task prompt sent to every agent.
46        task: String,
47        /// Maximum number of agents to run at once (defaults to all).
48        #[serde(default)]
49        concurrency: Option<usize>,
50    },
51    /// Run agents sequentially, passing results forward.
52    Chain {
53        /// Ordered sub-steps executed one after another.
54        steps: Vec<WorkflowStepDef>,
55    },
56    /// Fan-out: run an agent for each item in a SharedMemory key.
57    ForEach {
58        /// SharedMemory key holding the list of items to iterate over.
59        items_key: String,
60        /// Optional SharedMemory namespace to read the items from.
61        #[serde(default)]
62        namespace: Option<String>,
63        /// Name of the agent to run for each item.
64        agent: String,
65        /// Task template with `{item}` replaced by each item's value.
66        task_template: String,
67        /// Maximum number of agents to run at once (defaults to all).
68        #[serde(default)]
69        concurrency: Option<usize>,
70    },
71    /// Vote: ask multiple agents and aggregate by threshold.
72    Vote {
73        /// Names of the agents whose responses form the vote.
74        agents: Vec<String>,
75        /// Question posed to each agent.
76        question: String,
77        /// Fraction of matching responses required to reach consensus (0.0–1.0).
78        #[serde(default)]
79        threshold: Option<f32>,
80    },
81    /// Set a value in SharedMemory.
82    SetState {
83        /// SharedMemory key to write.
84        key: String,
85        /// Optional SharedMemory namespace to write into.
86        #[serde(default)]
87        namespace: Option<String>,
88        /// JSON value to store at the key.
89        value: Value,
90    },
91}
92
93impl WorkflowDefinition {
94    /// Load a workflow definition from a YAML file.
95    pub fn from_yaml_file(path: &str) -> Result<Self> {
96        let content = std::fs::read_to_string(path)
97            .with_context(|| format!("Failed to read workflow file: {}", path))?;
98        Self::from_yaml_str(&content)
99    }
100
101    /// Parse a workflow definition from a YAML string.
102    pub fn from_yaml_str(yaml: &str) -> Result<Self> {
103        let def: WorkflowDefinition =
104            serde_yaml::from_str(yaml).with_context(|| "Failed to parse workflow YAML")?;
105        def.validate()?;
106        Ok(def)
107    }
108
109    /// Validate the workflow definition.
110    fn validate(&self) -> Result<()> {
111        if self.name.is_empty() {
112            anyhow::bail!("Workflow name must not be empty");
113        }
114        if self.steps.is_empty() {
115            anyhow::bail!("Workflow must have at least one step");
116        }
117        // Recursively validate steps
118        for (i, step) in self.steps.iter().enumerate() {
119            Self::validate_step(step, i)?;
120        }
121        Ok(())
122    }
123
124    fn validate_step(step: &WorkflowStepDef, index: usize) -> Result<()> {
125        match step {
126            WorkflowStepDef::Run { agent, task, .. } => {
127                if agent.is_empty() {
128                    anyhow::bail!("Step {}: agent name must not be empty", index);
129                }
130                if task.is_empty() {
131                    anyhow::bail!("Step {}: task must not be empty", index);
132                }
133            }
134            WorkflowStepDef::Parallel {
135                agents,
136                task,
137                concurrency,
138                ..
139            } => {
140                if agents.is_empty() {
141                    anyhow::bail!("Step {}: parallel must have at least one agent", index);
142                }
143                if task.is_empty() {
144                    anyhow::bail!("Step {}: task must not be empty", index);
145                }
146                if let Some(c) = concurrency
147                    && *c == 0
148                {
149                    anyhow::bail!("Step {}: concurrency must be > 0", index);
150                }
151            }
152            WorkflowStepDef::Chain { steps } => {
153                if steps.is_empty() {
154                    anyhow::bail!("Step {}: chain must have at least one sub-step", index);
155                }
156                for (j, sub) in steps.iter().enumerate() {
157                    Self::validate_step(sub, j)?;
158                }
159            }
160            WorkflowStepDef::ForEach {
161                agent,
162                task_template,
163                ..
164            } => {
165                if agent.is_empty() {
166                    anyhow::bail!("Step {}: agent must not be empty", index);
167                }
168                if task_template.is_empty() {
169                    anyhow::bail!("Step {}: task_template must not be empty", index);
170                }
171            }
172            WorkflowStepDef::Vote {
173                agents, question, ..
174            } => {
175                if agents.is_empty() {
176                    anyhow::bail!("Step {}: vote must have at least one agent", index);
177                }
178                if question.is_empty() {
179                    anyhow::bail!("Step {}: question must not be empty", index);
180                }
181            }
182            WorkflowStepDef::SetState { key, .. } => {
183                if key.is_empty() {
184                    anyhow::bail!("Step {}: key must not be empty", index);
185                }
186            }
187        }
188        Ok(())
189    }
190
191    /// Count total steps (including nested).
192    pub fn step_count(&self) -> usize {
193        self.steps.iter().map(Self::count_step).sum()
194    }
195
196    fn count_step(step: &WorkflowStepDef) -> usize {
197        match step {
198            WorkflowStepDef::Chain { steps } => {
199                1 + steps.iter().map(Self::count_step).sum::<usize>()
200            }
201            _ => 1,
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_parse_simple_workflow() {
212        let yaml = r#"
213name: code-review
214steps:
215  - type: run
216    agent: reviewer
217    task: "Review the code changes"
218  - type: set_state
219    key: review_result
220    value: "pending"
221"#;
222        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
223        assert_eq!(wf.name, "code-review");
224        assert_eq!(wf.steps.len(), 2);
225        assert_eq!(wf.step_count(), 2);
226    }
227
228    #[test]
229    fn test_parse_parallel_workflow() {
230        let yaml = r#"
231name: parallel-test
232steps:
233  - type: parallel
234    agents: [agent-a, agent-b, agent-c]
235    task: "Analyze the codebase"
236    concurrency: 2
237"#;
238        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
239        assert_eq!(wf.name, "parallel-test");
240        match &wf.steps[0] {
241            WorkflowStepDef::Parallel {
242                agents,
243                concurrency,
244                ..
245            } => {
246                assert_eq!(agents.len(), 3);
247                assert_eq!(*concurrency, Some(2));
248            }
249            _ => panic!("Expected Parallel step"),
250        }
251    }
252
253    #[test]
254    fn test_parse_chain_workflow() {
255        let yaml = r#"
256name: pipeline
257steps:
258  - type: chain
259    steps:
260      - type: run
261        agent: designer
262        task: "Design the API"
263      - type: run
264        agent: implementer
265        task: "Implement the design"
266"#;
267        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
268        assert_eq!(wf.step_count(), 3); // 1 chain + 2 inner
269    }
270
271    #[test]
272    fn test_parse_vote_workflow() {
273        let yaml = r#"
274name: consensus
275steps:
276  - type: vote
277    agents: [agent-a, agent-b, agent-c]
278    question: "Which approach is best?"
279    threshold: 0.66
280"#;
281        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
282        match &wf.steps[0] {
283            WorkflowStepDef::Vote {
284                agents,
285                question,
286                threshold,
287            } => {
288                assert_eq!(agents.len(), 3);
289                assert_eq!(question, "Which approach is best?");
290                assert_eq!(*threshold, Some(0.66));
291            }
292            _ => panic!("Expected Vote step"),
293        }
294    }
295
296    #[test]
297    fn test_validation_empty_name() {
298        let yaml = r#"
299name: ""
300steps:
301  - type: run
302    agent: a
303    task: t
304"#;
305        assert!(WorkflowDefinition::from_yaml_str(yaml).is_err());
306    }
307
308    #[test]
309    fn test_validation_empty_steps() {
310        let yaml = r#"
311name: empty
312steps: []
313"#;
314        assert!(WorkflowDefinition::from_yaml_str(yaml).is_err());
315    }
316
317    #[test]
318    fn test_foreach_workflow() {
319        let yaml = r#"
320name: batch-process
321steps:
322  - type: for_each
323    items_key: file_list
324    agent: file-processor
325    task_template: "Process file: {item}"
326    concurrency: 4
327"#;
328        let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
329        match &wf.steps[0] {
330            WorkflowStepDef::ForEach {
331                items_key,
332                agent,
333                task_template,
334                concurrency,
335                ..
336            } => {
337                assert_eq!(items_key, "file_list");
338                assert_eq!(agent, "file-processor");
339                assert_eq!(task_template, "Process file: {item}");
340                assert_eq!(*concurrency, Some(4));
341            }
342            _ => panic!("Expected ForEach step"),
343        }
344    }
345}