Skip to main content

swink_agent_patterns/pipeline/
executor.rs

1//! Pipeline executor and agent factory traits.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use swink_agent::{Agent, AgentMessage, AgentResult, ContentBlock, LlmMessage};
7use tokio_util::sync::CancellationToken;
8
9use super::events::PipelineEvent;
10use super::output::{PipelineError, PipelineOutput, StepResult};
11use super::registry::PipelineRegistry;
12use super::types::{Pipeline, PipelineId};
13
14// ─── AgentFactory ───────────────────────────────────────────────────────────
15
16/// Trait for creating agents by name during pipeline execution.
17pub trait AgentFactory: Send + Sync {
18    /// Create an agent with the given name.
19    fn create(&self, name: &str) -> Result<Agent, PipelineError>;
20}
21
22// ─── SimpleAgentFactory ─────────────────────────────────────────────────────
23
24/// A basic agent factory backed by a name → builder-fn registry.
25pub struct SimpleAgentFactory {
26    builders: HashMap<String, Arc<dyn Fn() -> Agent + Send + Sync>>,
27}
28
29impl SimpleAgentFactory {
30    /// Create an empty factory.
31    pub fn new() -> Self {
32        Self {
33            builders: HashMap::new(),
34        }
35    }
36
37    /// Register a builder function for the given agent name.
38    pub fn register(
39        &mut self,
40        name: impl Into<String>,
41        builder: impl Fn() -> Agent + Send + Sync + 'static,
42    ) {
43        self.builders.insert(name.into(), Arc::new(builder));
44    }
45}
46
47impl Default for SimpleAgentFactory {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl AgentFactory for SimpleAgentFactory {
54    fn create(&self, name: &str) -> Result<Agent, PipelineError> {
55        let builder = self
56            .builders
57            .get(name)
58            .ok_or_else(|| PipelineError::AgentNotFound {
59                name: name.to_owned(),
60            })?;
61        Ok(builder())
62    }
63}
64
65// ─── PipelineExecutor ───────────────────────────────────────────────────────
66
67/// Orchestrates pipeline execution using an agent factory and registry.
68pub struct PipelineExecutor {
69    factory: Arc<dyn AgentFactory>,
70    registry: Arc<PipelineRegistry>,
71    event_handler: Option<Arc<dyn Fn(PipelineEvent) + Send + Sync>>,
72}
73
74impl PipelineExecutor {
75    /// Create a new executor with the given factory and registry.
76    pub fn new(factory: Arc<dyn AgentFactory>, registry: Arc<PipelineRegistry>) -> Self {
77        Self {
78            factory,
79            registry,
80            event_handler: None,
81        }
82    }
83
84    /// Set an event handler that receives pipeline lifecycle events.
85    #[must_use]
86    pub fn with_event_handler(
87        mut self,
88        handler: impl Fn(PipelineEvent) + Send + Sync + 'static,
89    ) -> Self {
90        self.event_handler = Some(Arc::new(handler));
91        self
92    }
93
94    /// Emit a pipeline event to the handler (if set).
95    fn emit(&self, event: PipelineEvent) {
96        if let Some(handler) = &self.event_handler {
97            handler(event);
98        }
99    }
100
101    /// Run a pipeline by ID.
102    pub async fn run(
103        &self,
104        pipeline_id: &PipelineId,
105        input: String,
106        cancellation_token: CancellationToken,
107    ) -> Result<PipelineOutput, PipelineError> {
108        let Some(pipeline) = self.registry.get(pipeline_id) else {
109            let err = PipelineError::PipelineNotFound {
110                id: pipeline_id.clone(),
111            };
112            self.emit(PipelineEvent::Failed {
113                pipeline_id: pipeline_id.clone(),
114                error_message: err.to_string(),
115            });
116            return Err(err);
117        };
118
119        let result = match pipeline {
120            Pipeline::Sequential {
121                id,
122                name,
123                steps,
124                pass_context,
125            } => {
126                self.run_sequential(id, name, steps, pass_context, input, cancellation_token)
127                    .await
128            }
129            Pipeline::Parallel {
130                id,
131                name,
132                branches,
133                merge_strategy,
134            } => {
135                super::parallel::run_parallel(
136                    &self.factory,
137                    self.event_handler.as_ref(),
138                    id,
139                    name,
140                    branches,
141                    merge_strategy,
142                    input,
143                    cancellation_token,
144                )
145                .await
146            }
147            Pipeline::Loop {
148                id,
149                name,
150                body,
151                exit_condition,
152                max_iterations,
153            } => {
154                super::loop_exec::run_loop(
155                    &self.factory,
156                    self.event_handler.as_ref(),
157                    id,
158                    name,
159                    body,
160                    exit_condition,
161                    max_iterations,
162                    input,
163                    cancellation_token,
164                )
165                .await
166            }
167        };
168
169        if let Err(err) = &result {
170            self.emit(PipelineEvent::Failed {
171                pipeline_id: pipeline_id.clone(),
172                error_message: err.to_string(),
173            });
174        }
175
176        result
177    }
178
179    async fn run_sequential(
180        &self,
181        id: PipelineId,
182        name: String,
183        steps: Vec<String>,
184        pass_context: bool,
185        input: String,
186        cancellation_token: CancellationToken,
187    ) -> Result<PipelineOutput, PipelineError> {
188        let start = std::time::Instant::now();
189        let mut step_results = Vec::new();
190        let mut current_input = input;
191        let mut total_usage = swink_agent::Usage::default();
192        // Accumulated message history for pass_context mode.
193        let mut context_messages: Vec<LlmMessage> = Vec::new();
194
195        self.emit(PipelineEvent::Started {
196            pipeline_id: id.clone(),
197            pipeline_name: name,
198        });
199
200        for (index, agent_name) in steps.iter().enumerate() {
201            if cancellation_token.is_cancelled() {
202                return Err(PipelineError::Cancelled);
203            }
204
205            self.emit(PipelineEvent::StepStarted {
206                pipeline_id: id.clone(),
207                step_index: index,
208                agent_name: agent_name.clone(),
209            });
210
211            let step_start = std::time::Instant::now();
212            let mut agent = self.factory.create(agent_name)?;
213
214            // Build input messages: either accumulated context or just the current input.
215            let messages = if pass_context && !context_messages.is_empty() {
216                let mut msgs: Vec<AgentMessage> = context_messages
217                    .iter()
218                    .map(|llm| AgentMessage::Llm(llm.clone()))
219                    .collect();
220                msgs.push(user_msg(&current_input));
221                msgs
222            } else {
223                vec![user_msg(&current_input)]
224            };
225
226            let result =
227                agent
228                    .prompt_async(messages)
229                    .await
230                    .map_err(|e| PipelineError::StepFailed {
231                        step_index: index,
232                        agent_name: agent_name.clone(),
233                        source: Box::new(e),
234                    })?;
235
236            let response = extract_text_response(&result);
237            let step_duration = step_start.elapsed();
238
239            total_usage += result.usage.clone();
240
241            self.emit(PipelineEvent::StepCompleted {
242                pipeline_id: id.clone(),
243                step_index: index,
244                agent_name: agent_name.clone(),
245                duration: step_duration,
246                usage: result.usage.clone(),
247            });
248
249            step_results.push(StepResult {
250                agent_name: agent_name.clone(),
251                response: response.clone(),
252                duration: step_duration,
253                usage: result.usage.clone(),
254            });
255
256            // In pass_context mode, accumulate the user message and assistant response.
257            if pass_context {
258                // Push the user message as an LlmMessage
259                context_messages.push(LlmMessage::User(
260                    swink_agent::UserMessage::new(vec![ContentBlock::Text {
261                        text: current_input.clone(),
262                    }])
263                    .with_timestamp(0),
264                ));
265                // Add the assistant messages from the result.
266                for msg in &result.messages {
267                    if let AgentMessage::Llm(llm @ LlmMessage::Assistant(_)) = msg {
268                        context_messages.push(llm.clone());
269                    }
270                }
271            }
272
273            current_input = response;
274        }
275
276        let total_duration = start.elapsed();
277        let final_response = step_results
278            .last()
279            .map(|s| s.response.clone())
280            .unwrap_or_default();
281
282        self.emit(PipelineEvent::Completed {
283            pipeline_id: id.clone(),
284            total_duration,
285            total_usage: total_usage.clone(),
286        });
287
288        Ok(PipelineOutput {
289            pipeline_id: id,
290            final_response,
291            steps: step_results,
292            total_duration,
293            total_usage,
294        })
295    }
296}
297
298/// Build a user message from text (local helper to avoid testkit dependency).
299fn user_msg(text: &str) -> AgentMessage {
300    AgentMessage::Llm(LlmMessage::User(
301        swink_agent::UserMessage::new(vec![ContentBlock::Text {
302            text: text.to_string(),
303        }])
304        .with_timestamp(0),
305    ))
306}
307
308/// Extract concatenated text content from an agent result's last assistant message.
309fn extract_text_response(result: &AgentResult) -> String {
310    result
311        .messages
312        .iter()
313        .rev()
314        .find_map(|m| match m {
315            AgentMessage::Llm(LlmMessage::Assistant(msg)) => Some(msg),
316            _ => None,
317        })
318        .map(|msg| {
319            msg.content
320                .iter()
321                .filter_map(|b| match b {
322                    ContentBlock::Text { text } => Some(text.as_str()),
323                    _ => None,
324                })
325                .collect::<String>()
326        })
327        .unwrap_or_default()
328}
329
330#[cfg(all(test, feature = "testkit"))]
331mod tests {
332    use super::*;
333    use std::sync::Arc;
334    use swink_agent::AgentOptions;
335    use swink_agent::testing::{MockStreamFn, default_convert, default_model, text_only_events};
336
337    fn make_agent() -> Agent {
338        let options = AgentOptions::new(
339            "test",
340            default_model(),
341            Arc::new(MockStreamFn::new(vec![])),
342            default_convert,
343        );
344        Agent::new(options)
345    }
346
347    fn make_text_agent(text: &str) -> Agent {
348        let events = text_only_events(text);
349        let options = AgentOptions::new(
350            "test",
351            default_model(),
352            Arc::new(MockStreamFn::new(vec![events])),
353            default_convert,
354        );
355        Agent::new(options)
356    }
357
358    // T017: SimpleAgentFactory tests
359
360    #[test]
361    fn factory_create_registered_agent_succeeds() {
362        let mut factory = SimpleAgentFactory::new();
363        factory.register("test-agent", make_agent);
364
365        let result = factory.create("test-agent");
366        assert!(result.is_ok());
367    }
368
369    #[test]
370    fn factory_create_unknown_returns_agent_not_found() {
371        let factory = SimpleAgentFactory::new();
372
373        let result = factory.create("nonexistent");
374        assert!(matches!(
375            result,
376            Err(PipelineError::AgentNotFound { name }) if name == "nonexistent"
377        ));
378    }
379
380    // T020-T024: Sequential pipeline tests
381
382    fn build_executor(factory: SimpleAgentFactory, registry: PipelineRegistry) -> PipelineExecutor {
383        PipelineExecutor::new(Arc::new(factory), Arc::new(registry))
384    }
385
386    #[tokio::test]
387    async fn sequential_two_step_pipeline() {
388        let mut factory = SimpleAgentFactory::new();
389        factory.register("agent-a", || make_text_agent("hello"));
390        factory.register("agent-b", || make_text_agent("world"));
391
392        let registry = PipelineRegistry::new();
393        let pipeline = Pipeline::sequential("two-step", vec!["agent-a".into(), "agent-b".into()]);
394        let id = pipeline.id().clone();
395        registry.register(pipeline);
396
397        let executor = build_executor(factory, registry);
398        let token = CancellationToken::new();
399
400        let output = executor.run(&id, "input".into(), token).await.unwrap();
401        assert_eq!(output.final_response, "world");
402        assert_eq!(output.steps.len(), 2);
403        assert_eq!(output.steps[0].agent_name, "agent-a");
404        assert_eq!(output.steps[0].response, "hello");
405        assert_eq!(output.steps[1].agent_name, "agent-b");
406        assert_eq!(output.steps[1].response, "world");
407    }
408
409    #[tokio::test]
410    async fn sequential_missing_step_agent_halts_with_error() {
411        // agent-b is not registered in the factory, causing AgentNotFound.
412        let mut factory = SimpleAgentFactory::new();
413        factory.register("agent-a", || make_text_agent("step-one"));
414        // agent-b intentionally not registered
415        factory.register("agent-c", || make_text_agent("step-three"));
416
417        let registry = PipelineRegistry::new();
418        let pipeline = Pipeline::sequential(
419            "three-step",
420            vec!["agent-a".into(), "agent-b".into(), "agent-c".into()],
421        );
422        let id = pipeline.id().clone();
423        registry.register(pipeline);
424
425        let executor = build_executor(factory, registry);
426        let token = CancellationToken::new();
427
428        let result = executor.run(&id, "input".into(), token).await;
429        assert!(result.is_err(), "expected error when step agent not found");
430        assert!(
431            matches!(result.unwrap_err(), PipelineError::AgentNotFound { name } if name == "agent-b"),
432            "expected AgentNotFound for agent-b"
433        );
434    }
435
436    #[tokio::test]
437    async fn sequential_missing_agent_returns_agent_not_found() {
438        let factory = SimpleAgentFactory::new(); // no agents registered
439
440        let registry = PipelineRegistry::new();
441        let pipeline = Pipeline::sequential("missing", vec!["ghost".into()]);
442        let id = pipeline.id().clone();
443        registry.register(pipeline);
444
445        let executor = build_executor(factory, registry);
446        let token = CancellationToken::new();
447
448        let result = executor.run(&id, "input".into(), token).await;
449        assert!(matches!(
450            result,
451            Err(PipelineError::AgentNotFound { name }) if name == "ghost"
452        ));
453    }
454
455    #[tokio::test]
456    async fn sequential_zero_steps_returns_empty() {
457        let factory = SimpleAgentFactory::new();
458
459        let registry = PipelineRegistry::new();
460        let pipeline = Pipeline::sequential("empty", vec![]);
461        let id = pipeline.id().clone();
462        registry.register(pipeline);
463
464        let executor = build_executor(factory, registry);
465        let token = CancellationToken::new();
466
467        let output = executor.run(&id, "input".into(), token).await.unwrap();
468        assert!(output.steps.is_empty());
469        assert!(output.final_response.is_empty());
470    }
471
472    #[tokio::test]
473    async fn run_unknown_pipeline_returns_not_found() {
474        let factory = SimpleAgentFactory::new();
475        let registry = PipelineRegistry::new();
476
477        let executor = build_executor(factory, registry);
478        let token = CancellationToken::new();
479        let unknown_id = PipelineId::new("nonexistent");
480
481        let result = executor.run(&unknown_id, "input".into(), token).await;
482        assert!(matches!(
483            result,
484            Err(PipelineError::PipelineNotFound { id }) if id == unknown_id
485        ));
486    }
487}