1use 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 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 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 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 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 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}