Skip to main content

car_multi/patterns/
map_reduce.rs

1//! Map-Reduce — split a task into N items, run agents in parallel, reduce results.
2//!
3//! The mapper spec is cloned for each item. Each mapper processes one item.
4//! The reducer combines all mapper outputs into a single result.
5
6use crate::error::MultiError;
7use crate::mailbox::Mailbox;
8use crate::runner::AgentRunner;
9use crate::shared::SharedInfra;
10use crate::types::{AgentOutput, AgentSpec};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::Arc;
14use tokio::task::JoinSet;
15use tracing::instrument;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MapReduceResult {
19    pub task: String,
20    pub map_outputs: Vec<AgentOutput>,
21    pub reduced_answer: String,
22}
23
24impl MapReduceResult {
25    pub fn all_succeeded(&self) -> bool {
26        self.map_outputs.iter().all(|o| o.succeeded())
27    }
28}
29
30pub struct MapReduce {
31    pub mapper: AgentSpec,
32    pub reducer: AgentSpec,
33    pub max_concurrent: usize,
34}
35
36impl MapReduce {
37    pub fn new(mapper: AgentSpec, reducer: AgentSpec) -> Self {
38        Self {
39            mapper,
40            reducer,
41            max_concurrent: 5,
42        }
43    }
44
45    pub fn with_max_concurrent(mut self, n: usize) -> Self {
46        self.max_concurrent = n;
47        self
48    }
49
50    #[instrument(name = "multi.map_reduce", skip_all)]
51    pub async fn run(
52        &self,
53        task: &str,
54        items: &[String],
55        runner: &Arc<dyn AgentRunner>,
56        infra: &SharedInfra,
57    ) -> Result<MapReduceResult, MultiError> {
58        // Map phase: one agent per item, bounded concurrency
59        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.max_concurrent));
60        let mut handles = JoinSet::new();
61        let mut task_indices = HashMap::new();
62        let mut indexed: Vec<(usize, AgentOutput)> = Vec::new();
63
64        for (i, item) in items.iter().enumerate() {
65            // Budget gate per mapper. Denied items are recorded as skipped and
66            // never spawned.
67            if let Err(e) = infra.begin_agent() {
68                let name = format!("{}_{}", self.mapper.name, i);
69                indexed.push((i, crate::budget::budget_skipped_output(&name, &e)));
70                continue;
71            }
72
73            let sem = Arc::clone(&semaphore);
74            let runner = Arc::clone(runner);
75            let rt = infra.make_runtime();
76            let mailbox = Mailbox::default();
77
78            let mut spec = self.mapper.clone();
79            spec.name = format!("{}_{}", self.mapper.name, i);
80
81            for tool in &spec.tools {
82                rt.register_tool(tool).await;
83            }
84
85            let subtask = format!("{}\n\nProcess this item: {}", task, item);
86
87            let handle = handles.spawn(async move {
88                let _permit = sem.acquire().await.unwrap();
89                (i, runner.run(&spec, &subtask, &rt, &mailbox).await)
90            });
91            task_indices.insert(handle.id(), i);
92        }
93
94        while let Some(result) = handles.join_next().await {
95            match result {
96                Ok((i, Ok(output))) => {
97                    infra.record_output(&output);
98                    indexed.push((i, output));
99                }
100                Ok((i, Err(e))) => {
101                    // Spend before a runner Err is not metered (no token payload
102                    // on the error path) — the budget can under-count failures.
103                    indexed.push((
104                        i,
105                        AgentOutput {
106                            name: format!("{}_{}", self.mapper.name, i),
107                            answer: String::new(),
108                            turns: 0,
109                            tool_calls: 0,
110                            duration_ms: 0.0,
111                            error: Some(e.to_string()),
112                            outcome: None,
113                            tokens: None,
114                            tools_used: Vec::new(),
115                        },
116                    ));
117                }
118                Err(e) => {
119                    let i = task_indices
120                        .get(&e.id())
121                        .copied()
122                        .expect("mapper task id should be tracked");
123                    indexed.push((
124                        i,
125                        AgentOutput {
126                            name: format!("{}_{}", self.mapper.name, i),
127                            answer: String::new(),
128                            turns: 0,
129                            tool_calls: 0,
130                            duration_ms: 0.0,
131                            error: Some(format!("join error: {}", e)),
132                            outcome: None,
133                            tokens: None,
134                            tools_used: Vec::new(),
135                        },
136                    ));
137                }
138            }
139        }
140
141        indexed.sort_by_key(|(i, _)| *i);
142        let map_outputs: Vec<AgentOutput> = indexed.into_iter().map(|(_, o)| o).collect();
143
144        // Reduce phase
145        let summaries: Vec<String> = map_outputs
146            .iter()
147            .filter(|o| o.succeeded())
148            .map(|o| format!("- [{}] {}", o.name, truncate(&o.answer, 300)))
149            .collect();
150
151        let reduce_task = format!(
152            "Original task: {}\n\nResults from {} sub-agents:\n{}\n\n\
153             Combine these into a single coherent result.",
154            task,
155            map_outputs.len(),
156            summaries.join("\n")
157        );
158
159        // Gate the reducer; on denial return the unreduced map summaries so the
160        // caller still gets the mapper work it paid for.
161        let reduced = if infra.begin_agent().is_ok() {
162            let rt = infra.make_runtime();
163            let mailbox = Mailbox::default();
164            match runner.run(&self.reducer, &reduce_task, &rt, &mailbox).await {
165                Ok(o) => {
166                    infra.record_output(&o);
167                    o.answer
168                }
169                Err(_) => String::new(),
170            }
171        } else {
172            summaries.join("\n")
173        };
174
175        Ok(MapReduceResult {
176            task: task.to_string(),
177            map_outputs,
178            reduced_answer: reduced,
179        })
180    }
181}
182
183fn truncate(s: &str, max_len: usize) -> &str {
184    if s.len() <= max_len {
185        return s;
186    }
187    let mut end = max_len;
188    while end > 0 && !s.is_char_boundary(end) {
189        end -= 1;
190    }
191    &s[..end]
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::types::{AgentOutput, AgentSpec};
198    use car_engine::Runtime;
199    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
200    use tokio::sync::Notify;
201
202    struct CountRunner;
203
204    #[async_trait::async_trait]
205    impl crate::runner::AgentRunner for CountRunner {
206        async fn run(
207            &self,
208            spec: &AgentSpec,
209            _task: &str,
210            _runtime: &Runtime,
211            _mailbox: &Mailbox,
212        ) -> Result<AgentOutput, MultiError> {
213            Ok(AgentOutput {
214                name: spec.name.clone(),
215                answer: format!("{} processed", spec.name),
216                turns: 1,
217                tool_calls: 0,
218                duration_ms: 5.0,
219                error: None,
220                outcome: None,
221                tokens: None,
222                tools_used: Vec::new(),
223            })
224        }
225    }
226
227    #[tokio::test]
228    async fn test_map_reduce() {
229        let mapper = AgentSpec::new("summarizer", "Summarize the file");
230        let reducer = AgentSpec::new("combiner", "Combine summaries");
231        let items: Vec<String> = vec!["file_a.rs", "file_b.rs", "file_c.rs"]
232            .into_iter()
233            .map(String::from)
234            .collect();
235
236        let runner: Arc<dyn crate::runner::AgentRunner> = Arc::new(CountRunner);
237        let infra = SharedInfra::new();
238
239        let result = MapReduce::new(mapper, reducer)
240            .run("summarize codebase", &items, &runner, &infra)
241            .await
242            .unwrap();
243
244        assert_eq!(result.map_outputs.len(), 3);
245        assert!(!result.reduced_answer.is_empty());
246    }
247
248    struct LaterPanicRunner {
249        later_panicked: Arc<AtomicBool>,
250        notify: Arc<Notify>,
251    }
252
253    #[async_trait::async_trait]
254    impl crate::runner::AgentRunner for LaterPanicRunner {
255        async fn run(
256            &self,
257            spec: &AgentSpec,
258            _task: &str,
259            _runtime: &Runtime,
260            _mailbox: &Mailbox,
261        ) -> Result<AgentOutput, MultiError> {
262            match spec.name.as_str() {
263                "mapper_0" => {
264                    while !self.later_panicked.load(Ordering::SeqCst) {
265                        self.notify.notified().await;
266                    }
267                    Ok(AgentOutput {
268                        name: spec.name.clone(),
269                        answer: "mapper 0 completed".to_string(),
270                        turns: 1,
271                        tool_calls: 0,
272                        duration_ms: 5.0,
273                        error: None,
274                        outcome: None,
275                        tokens: None,
276                        tools_used: Vec::new(),
277                    })
278                }
279                "mapper_1" => {
280                    self.later_panicked.store(true, Ordering::SeqCst);
281                    self.notify.notify_one();
282                    panic!("mapper 1 panicked first");
283                }
284                _ => Ok(AgentOutput {
285                    name: spec.name.clone(),
286                    answer: "reduced".to_string(),
287                    turns: 1,
288                    tool_calls: 0,
289                    duration_ms: 5.0,
290                    error: None,
291                    outcome: None,
292                    tokens: None,
293                    tools_used: Vec::new(),
294                }),
295            }
296        }
297    }
298
299    #[tokio::test]
300    async fn panicking_mapper_keeps_original_item_index() {
301        let runner: Arc<dyn crate::runner::AgentRunner> = Arc::new(LaterPanicRunner {
302            later_panicked: Arc::new(AtomicBool::new(false)),
303            notify: Arc::new(Notify::new()),
304        });
305        let infra = SharedInfra::new();
306        let items = vec!["slow first".to_string(), "fast panic".to_string()];
307
308        let result = MapReduce::new(
309            AgentSpec::new("mapper", "map item"),
310            AgentSpec::new("reducer", "reduce items"),
311        )
312        .with_max_concurrent(2)
313        .run("preserve mapper order", &items, &runner, &infra)
314        .await
315        .unwrap();
316
317        assert_eq!(result.map_outputs.len(), 2);
318        assert_eq!(result.map_outputs[0].name, "mapper_0");
319        assert!(result.map_outputs[0].succeeded());
320        assert_eq!(result.map_outputs[1].name, "mapper_1");
321        assert!(
322            result.map_outputs[1]
323                .error
324                .as_deref()
325                .is_some_and(|error| error.contains("panicked")),
326            "expected mapper_1 to carry the panic error, got {:?}",
327            result.map_outputs[1].error
328        );
329    }
330
331    struct DropCountingRunner {
332        started: Arc<AtomicUsize>,
333        dropped: Arc<AtomicUsize>,
334        notify: Arc<Notify>,
335    }
336
337    struct DropGuard(Arc<AtomicUsize>);
338
339    impl Drop for DropGuard {
340        fn drop(&mut self) {
341            self.0.fetch_add(1, Ordering::SeqCst);
342        }
343    }
344
345    #[async_trait::async_trait]
346    impl crate::runner::AgentRunner for DropCountingRunner {
347        async fn run(
348            &self,
349            _spec: &AgentSpec,
350            _task: &str,
351            _runtime: &Runtime,
352            _mailbox: &Mailbox,
353        ) -> Result<AgentOutput, MultiError> {
354            let _guard = DropGuard(self.dropped.clone());
355            self.started.fetch_add(1, Ordering::SeqCst);
356            self.notify.notify_one();
357            std::future::pending::<Result<AgentOutput, MultiError>>().await
358        }
359    }
360
361    #[tokio::test]
362    async fn dropping_map_reduce_run_aborts_mapper_tasks() {
363        let started = Arc::new(AtomicUsize::new(0));
364        let dropped = Arc::new(AtomicUsize::new(0));
365        let notify = Arc::new(Notify::new());
366        let runner: Arc<dyn crate::runner::AgentRunner> = Arc::new(DropCountingRunner {
367            started: started.clone(),
368            dropped: dropped.clone(),
369            notify: notify.clone(),
370        });
371        let infra = SharedInfra::new();
372        let items = vec!["one".to_string(), "two".to_string()];
373
374        let handle = tokio::spawn(async move {
375            MapReduce::new(
376                AgentSpec::new("worker", "run work"),
377                AgentSpec::new("reducer", "reduce work"),
378            )
379            .with_max_concurrent(2)
380            .run("parallel goal", &items, &runner, &infra)
381            .await
382        });
383
384        while started.load(Ordering::SeqCst) < 2 {
385            notify.notified().await;
386        }
387
388        handle.abort();
389        assert!(handle.await.unwrap_err().is_cancelled());
390
391        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
392        while std::time::Instant::now() < deadline {
393            if dropped.load(Ordering::SeqCst) >= 2 {
394                return;
395            }
396            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
397        }
398        panic!(
399            "mapper futures were detached after MapReduce cancellation; dropped={}",
400            dropped.load(Ordering::SeqCst)
401        );
402    }
403}