Skip to main content

swink_agent_patterns/pipeline/
types.rs

1//! Core pipeline types: PipelineId, Pipeline, MergeStrategy, ExitCondition.
2
3use std::fmt;
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9// ─── PipelineId ─────────────────────────────────────────────────────────────
10
11/// Unique identifier for a pipeline definition.
12#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct PipelineId(String);
14
15impl PipelineId {
16    /// Create a pipeline ID from a string.
17    pub fn new(id: impl Into<String>) -> Self {
18        Self(id.into())
19    }
20
21    /// Generate a unique pipeline ID using UUID v4.
22    pub fn generate() -> Self {
23        Self(Uuid::new_v4().to_string())
24    }
25}
26
27impl fmt::Display for PipelineId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(&self.0)
30    }
31}
32
33// ─── MergeStrategy ──────────────────────────────────────────────────────────
34
35/// Controls how parallel branch outputs are combined.
36#[non_exhaustive]
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub enum MergeStrategy {
39    /// Join all outputs in declaration order with a separator.
40    Concat { separator: String },
41    /// Return the first branch to complete.
42    First,
43    /// Return the first N branches to complete.
44    Fastest { n: usize },
45    /// Pass all outputs to a named aggregator agent.
46    Custom { aggregator: String },
47}
48
49// ─── ExitCondition ──────────────────────────────────────────────────────────
50
51/// Controls when a loop pipeline terminates.
52#[non_exhaustive]
53#[derive(Clone, Debug)]
54pub enum ExitCondition {
55    /// Exit when the body agent invokes the named tool.
56    ToolCalled { tool_name: String },
57    /// Exit when the output matches the regex pattern.
58    OutputContains {
59        pattern: String,
60        #[allow(dead_code)]
61        compiled: Regex,
62    },
63    /// Always run to the max_iterations cap.
64    MaxIterations,
65}
66
67impl ExitCondition {
68    /// Create an `OutputContains` condition, eagerly validating the regex.
69    ///
70    /// Returns `Err` if the pattern is not a valid regex.
71    pub fn output_contains(pattern: impl Into<String>) -> Result<Self, String> {
72        let pattern = pattern.into();
73        let compiled =
74            Regex::new(&pattern).map_err(|e| format!("invalid regex '{pattern}': {e}"))?;
75        Ok(Self::OutputContains { pattern, compiled })
76    }
77}
78
79impl Serialize for ExitCondition {
80    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
81        #[derive(Serialize)]
82        #[serde(tag = "type")]
83        enum Helper<'a> {
84            ToolCalled { tool_name: &'a str },
85            OutputContains { pattern: &'a str },
86            MaxIterations,
87        }
88
89        match self {
90            Self::ToolCalled { tool_name } => {
91                Helper::ToolCalled { tool_name }.serialize(serializer)
92            }
93            Self::OutputContains { pattern, .. } => {
94                Helper::OutputContains { pattern }.serialize(serializer)
95            }
96            Self::MaxIterations => Helper::MaxIterations.serialize(serializer),
97        }
98    }
99}
100
101impl<'de> Deserialize<'de> for ExitCondition {
102    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
103        #[derive(Deserialize)]
104        #[serde(tag = "type")]
105        enum Helper {
106            ToolCalled { tool_name: String },
107            OutputContains { pattern: String },
108            MaxIterations,
109        }
110
111        let h = Helper::deserialize(deserializer)?;
112        match h {
113            Helper::ToolCalled { tool_name } => Ok(Self::ToolCalled { tool_name }),
114            Helper::OutputContains { pattern } => {
115                let compiled = Regex::new(&pattern).map_err(serde::de::Error::custom)?;
116                Ok(Self::OutputContains { pattern, compiled })
117            }
118            Helper::MaxIterations => Ok(Self::MaxIterations),
119        }
120    }
121}
122
123// ─── Pipeline ───────────────────────────────────────────────────────────────
124
125/// A pipeline definition describing how to compose multiple agents.
126#[non_exhaustive]
127#[derive(Clone, Debug, Serialize, Deserialize)]
128#[serde(tag = "type")]
129pub enum Pipeline {
130    /// Execute agents in declared order, passing output forward.
131    Sequential {
132        id: PipelineId,
133        name: String,
134        steps: Vec<String>,
135        pass_context: bool,
136    },
137    /// Execute agents concurrently and merge results.
138    Parallel {
139        id: PipelineId,
140        name: String,
141        branches: Vec<String>,
142        merge_strategy: MergeStrategy,
143    },
144    /// Execute an agent repeatedly until an exit condition is met.
145    Loop {
146        id: PipelineId,
147        name: String,
148        body: String,
149        exit_condition: ExitCondition,
150        max_iterations: usize,
151    },
152}
153
154impl Pipeline {
155    /// Create a sequential pipeline without context passing.
156    pub fn sequential(name: impl Into<String>, steps: Vec<String>) -> Self {
157        Self::Sequential {
158            id: PipelineId::generate(),
159            name: name.into(),
160            steps,
161            pass_context: false,
162        }
163    }
164
165    /// Create a sequential pipeline with context passing enabled.
166    pub fn sequential_with_context(name: impl Into<String>, steps: Vec<String>) -> Self {
167        Self::Sequential {
168            id: PipelineId::generate(),
169            name: name.into(),
170            steps,
171            pass_context: true,
172        }
173    }
174
175    /// Create a parallel pipeline.
176    pub fn parallel(
177        name: impl Into<String>,
178        branches: Vec<String>,
179        merge_strategy: MergeStrategy,
180    ) -> Self {
181        Self::Parallel {
182            id: PipelineId::generate(),
183            name: name.into(),
184            branches,
185            merge_strategy,
186        }
187    }
188
189    /// Create a loop pipeline.
190    pub fn loop_(
191        name: impl Into<String>,
192        body: impl Into<String>,
193        exit_condition: ExitCondition,
194    ) -> Self {
195        Self::Loop {
196            id: PipelineId::generate(),
197            name: name.into(),
198            body: body.into(),
199            exit_condition,
200            max_iterations: 10,
201        }
202    }
203
204    /// Create a loop pipeline with a custom max iterations cap.
205    pub fn loop_with_max(
206        name: impl Into<String>,
207        body: impl Into<String>,
208        exit_condition: ExitCondition,
209        max_iterations: usize,
210    ) -> Self {
211        Self::Loop {
212            id: PipelineId::generate(),
213            name: name.into(),
214            body: body.into(),
215            exit_condition,
216            max_iterations,
217        }
218    }
219
220    /// Override the auto-generated ID.
221    #[must_use]
222    pub fn with_id(mut self, id: PipelineId) -> Self {
223        match &mut self {
224            Self::Sequential { id: i, .. }
225            | Self::Parallel { id: i, .. }
226            | Self::Loop { id: i, .. } => *i = id,
227        }
228        self
229    }
230
231    /// Returns the pipeline's unique identifier.
232    pub fn id(&self) -> &PipelineId {
233        match self {
234            Self::Sequential { id, .. } | Self::Parallel { id, .. } | Self::Loop { id, .. } => id,
235        }
236    }
237
238    /// Returns the pipeline's human-readable name.
239    pub fn name(&self) -> &str {
240        match self {
241            Self::Sequential { name, .. }
242            | Self::Parallel { name, .. }
243            | Self::Loop { name, .. } => name,
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::collections::HashSet;
252
253    // T014: PipelineId tests
254
255    #[test]
256    fn pipeline_id_new_and_display() {
257        let id = PipelineId::new("test-pipeline");
258        assert_eq!(id.to_string(), "test-pipeline");
259    }
260
261    #[test]
262    fn pipeline_id_generate_is_unique() {
263        let a = PipelineId::generate();
264        let b = PipelineId::generate();
265        assert_ne!(a, b);
266    }
267
268    #[test]
269    fn pipeline_id_equality_and_hashing() {
270        let a = PipelineId::new("same");
271        let b = PipelineId::new("same");
272        assert_eq!(a, b);
273
274        let mut set = HashSet::new();
275        set.insert(a);
276        assert!(set.contains(&b));
277    }
278
279    #[test]
280    fn pipeline_id_serde_roundtrip() {
281        let id = PipelineId::new("round-trip");
282        let json = serde_json::to_string(&id).unwrap();
283        let parsed: PipelineId = serde_json::from_str(&json).unwrap();
284        assert_eq!(id, parsed);
285    }
286
287    // T015: ExitCondition tests
288
289    #[test]
290    fn exit_condition_output_contains_valid_regex() {
291        let cond = ExitCondition::output_contains(r"\bDONE\b").unwrap();
292        match cond {
293            ExitCondition::OutputContains { pattern, compiled } => {
294                assert_eq!(pattern, r"\bDONE\b");
295                assert!(compiled.is_match("task DONE here"));
296            }
297            _ => panic!("expected OutputContains"),
298        }
299    }
300
301    #[test]
302    fn exit_condition_output_contains_invalid_regex() {
303        let result = ExitCondition::output_contains("[invalid");
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn exit_condition_serde_roundtrip_recompiles() {
309        let cond = ExitCondition::output_contains(r"done|finished").unwrap();
310        let json = serde_json::to_string(&cond).unwrap();
311        let parsed: ExitCondition = serde_json::from_str(&json).unwrap();
312        match parsed {
313            ExitCondition::OutputContains { pattern, compiled } => {
314                assert_eq!(pattern, "done|finished");
315                assert!(compiled.is_match("all done"));
316            }
317            _ => panic!("expected OutputContains"),
318        }
319    }
320
321    // T016: Pipeline constructor tests
322
323    #[test]
324    fn sequential_constructor() {
325        let p = Pipeline::sequential("test", vec!["a".into(), "b".into()]);
326        assert_eq!(p.name(), "test");
327        match &p {
328            Pipeline::Sequential {
329                pass_context,
330                steps,
331                ..
332            } => {
333                assert!(!pass_context);
334                assert_eq!(steps.len(), 2);
335            }
336            _ => panic!("expected Sequential"),
337        }
338    }
339
340    #[test]
341    fn parallel_constructor() {
342        let p = Pipeline::parallel("par", vec!["x".into(), "y".into()], MergeStrategy::First);
343        assert_eq!(p.name(), "par");
344        assert!(matches!(p, Pipeline::Parallel { .. }));
345    }
346
347    #[test]
348    fn loop_constructor() {
349        let p = Pipeline::loop_("lp", "body-agent", ExitCondition::MaxIterations);
350        assert_eq!(p.name(), "lp");
351        match &p {
352            Pipeline::Loop { max_iterations, .. } => assert_eq!(*max_iterations, 10),
353            _ => panic!("expected Loop"),
354        }
355    }
356
357    #[test]
358    fn with_id_overrides_generated_id() {
359        let custom = PipelineId::new("custom-id");
360        let p = Pipeline::sequential("s", vec![]).with_id(custom.clone());
361        assert_eq!(*p.id(), custom);
362    }
363
364    #[test]
365    fn auto_generated_ids_are_unique() {
366        let a = Pipeline::sequential("a", vec![]);
367        let b = Pipeline::sequential("b", vec![]);
368        assert_ne!(a.id(), b.id());
369    }
370}