1use std::sync::Arc;
8
9use tokio::sync::Semaphore;
10use tokio::task::JoinSet;
11
12use crate::error::Error;
13use crate::llm::LlmProvider;
14use crate::llm::types::TokenUsage;
15
16use super::AgentOutput;
17use super::AgentRunner;
18
19#[derive(Debug)]
21pub struct BatchResult {
22 pub index: usize,
24 pub input: String,
26 pub result: Result<AgentOutput, Error>,
28}
29
30#[derive(Debug, Clone)]
32pub struct BatchConfig {
33 pub max_concurrency: usize,
35}
36
37impl Default for BatchConfig {
38 fn default() -> Self {
39 Self {
40 max_concurrency: std::thread::available_parallelism()
41 .map(|n| n.get())
42 .unwrap_or(4),
43 }
44 }
45}
46
47pub struct BatchExecutor<P: LlmProvider + 'static> {
52 agent: Arc<AgentRunner<P>>,
53 config: BatchConfig,
54}
55
56impl<P: LlmProvider + 'static> std::fmt::Debug for BatchExecutor<P> {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("BatchExecutor")
59 .field("max_concurrency", &self.config.max_concurrency)
60 .finish()
61 }
62}
63
64pub struct BatchExecutorBuilder<P: LlmProvider + 'static> {
66 agent: AgentRunner<P>,
67 max_concurrency: Option<usize>,
68}
69
70impl<P: LlmProvider + 'static> BatchExecutor<P> {
71 pub fn builder(agent: AgentRunner<P>) -> BatchExecutorBuilder<P> {
73 BatchExecutorBuilder {
74 agent,
75 max_concurrency: None,
76 }
77 }
78
79 pub async fn execute(&self, tasks: Vec<String>) -> Vec<BatchResult> {
83 if tasks.is_empty() {
84 return Vec::new();
85 }
86
87 let semaphore = Arc::new(Semaphore::new(self.config.max_concurrency));
88 let mut set = JoinSet::new();
89
90 for (index, input) in tasks.into_iter().enumerate() {
91 let agent = Arc::clone(&self.agent);
92 let sem = Arc::clone(&semaphore);
93 set.spawn(async move {
94 let _permit = sem.acquire().await.expect("semaphore closed unexpectedly");
95 let result = agent.execute(&input).await;
96 BatchResult {
97 index,
98 input,
99 result,
100 }
101 });
102 }
103
104 let mut results = Vec::with_capacity(set.len());
105 while let Some(join_result) = set.join_next().await {
106 match join_result {
107 Ok(batch_result) => results.push(batch_result),
108 Err(e) => {
109 tracing::error!("batch task panicked: {e}");
113 }
114 }
115 }
116
117 results.sort_by_key(|r| r.index);
118 results
119 }
120
121 pub async fn execute_ref(&self, tasks: &[&str]) -> Vec<BatchResult> {
123 let owned: Vec<String> = tasks.iter().map(|s| (*s).to_string()).collect();
124 self.execute(owned).await
125 }
126
127 pub fn aggregate_usage(results: &[BatchResult]) -> TokenUsage {
129 let mut total = TokenUsage::default();
130 for r in results {
131 if let Ok(output) = &r.result {
132 total += output.tokens_used;
133 }
134 }
135 total
136 }
137}
138
139impl<P: LlmProvider + 'static> BatchExecutorBuilder<P> {
140 pub fn max_concurrency(mut self, n: usize) -> Self {
142 self.max_concurrency = Some(n);
143 self
144 }
145
146 pub fn build(self) -> Result<BatchExecutor<P>, Error> {
148 let config = match self.max_concurrency {
149 Some(n) => {
150 if n == 0 {
151 return Err(Error::Config(
152 "BatchExecutor max_concurrency must be at least 1".into(),
153 ));
154 }
155 BatchConfig { max_concurrency: n }
156 }
157 None => BatchConfig::default(),
158 };
159 Ok(BatchExecutor {
160 agent: Arc::new(self.agent),
161 config,
162 })
163 }
164}
165
166#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::agent::test_helpers::{MockProvider, make_agent};
174 use crate::llm::types::{CompletionRequest, CompletionResponse, ContentBlock, StopReason};
175 use std::sync::atomic::{AtomicUsize, Ordering};
176
177 struct ConcurrencyTrackingProvider {
179 current: Arc<AtomicUsize>,
181 peak: Arc<AtomicUsize>,
183 response_text: String,
184 }
185
186 impl ConcurrencyTrackingProvider {
187 fn new(current: Arc<AtomicUsize>, peak: Arc<AtomicUsize>, response_text: &str) -> Self {
188 Self {
189 current,
190 peak,
191 response_text: response_text.to_string(),
192 }
193 }
194 }
195
196 impl LlmProvider for ConcurrencyTrackingProvider {
197 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
198 let prev = self.current.fetch_add(1, Ordering::SeqCst);
199 let concurrent = prev + 1;
200 self.peak.fetch_max(concurrent, Ordering::SeqCst);
202 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
204 self.current.fetch_sub(1, Ordering::SeqCst);
205
206 Ok(CompletionResponse {
207 content: vec![ContentBlock::Text {
208 text: self.response_text.clone(),
209 }],
210 stop_reason: StopReason::EndTurn,
211 reasoning: None,
212 usage: TokenUsage {
213 input_tokens: 10,
214 output_tokens: 5,
215 ..Default::default()
216 },
217 model: None,
218 })
219 }
220
221 fn model_name(&self) -> Option<&str> {
222 Some("concurrency-mock")
223 }
224 }
225
226 #[test]
231 fn builder_uses_default_concurrency() {
232 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
233 "ok", 10, 5,
234 )]));
235 let agent = make_agent(provider, "test");
236 let executor = BatchExecutor::builder(agent).build().unwrap();
237 assert!(executor.config.max_concurrency >= 1);
238 }
239
240 #[test]
241 fn builder_accepts_custom_concurrency() {
242 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
243 "ok", 10, 5,
244 )]));
245 let agent = make_agent(provider, "test");
246 let executor = BatchExecutor::builder(agent)
247 .max_concurrency(8)
248 .build()
249 .unwrap();
250 assert_eq!(executor.config.max_concurrency, 8);
251 }
252
253 #[test]
254 fn builder_rejects_zero_concurrency() {
255 let provider = Arc::new(MockProvider::new(vec![]));
256 let agent = make_agent(provider, "test");
257 let result = BatchExecutor::builder(agent).max_concurrency(0).build();
258 assert!(result.is_err());
259 assert!(result.unwrap_err().to_string().contains("at least 1"));
260 }
261
262 #[test]
263 fn debug_impl() {
264 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
265 "ok", 10, 5,
266 )]));
267 let agent = make_agent(provider, "test");
268 let executor = BatchExecutor::builder(agent)
269 .max_concurrency(3)
270 .build()
271 .unwrap();
272 let debug = format!("{executor:?}");
273 assert!(debug.contains("BatchExecutor"));
274 assert!(debug.contains("3"));
275 }
276
277 #[tokio::test]
282 async fn empty_batch_returns_empty_vec() {
283 let provider = Arc::new(MockProvider::new(vec![]));
284 let agent = make_agent(provider, "test");
285 let executor = BatchExecutor::builder(agent)
286 .max_concurrency(2)
287 .build()
288 .unwrap();
289
290 let results = executor.execute(vec![]).await;
291 assert!(results.is_empty());
292 }
293
294 #[tokio::test]
295 async fn single_task_succeeds() {
296 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
297 "hello", 100, 50,
298 )]));
299 let agent = make_agent(provider, "test");
300 let executor = BatchExecutor::builder(agent)
301 .max_concurrency(2)
302 .build()
303 .unwrap();
304
305 let results = executor.execute(vec!["task1".to_string()]).await;
306 assert_eq!(results.len(), 1);
307 assert_eq!(results[0].index, 0);
308 assert_eq!(results[0].input, "task1");
309 let output = results[0].result.as_ref().unwrap();
310 assert_eq!(output.result, "hello");
311 assert_eq!(output.tokens_used.input_tokens, 100);
312 assert_eq!(output.tokens_used.output_tokens, 50);
313 }
314
315 #[tokio::test]
316 async fn multiple_tasks_all_succeed() {
317 let provider = Arc::new(MockProvider::new(vec![
318 MockProvider::text_response("r1", 10, 5),
319 MockProvider::text_response("r2", 20, 10),
320 MockProvider::text_response("r3", 30, 15),
321 MockProvider::text_response("r4", 40, 20),
322 MockProvider::text_response("r5", 50, 25),
323 ]));
324 let agent = make_agent(provider, "test");
325 let executor = BatchExecutor::builder(agent)
326 .max_concurrency(5)
327 .build()
328 .unwrap();
329
330 let tasks: Vec<String> = (1..=5).map(|i| format!("task{i}")).collect();
331 let results = executor.execute(tasks).await;
332
333 assert_eq!(results.len(), 5);
334 for r in &results {
335 assert!(r.result.is_ok(), "task {} failed: {:?}", r.index, r.result);
336 }
337 }
338
339 #[tokio::test]
340 async fn results_ordered_by_index() {
341 let provider = Arc::new(MockProvider::new(vec![
342 MockProvider::text_response("a", 10, 5),
343 MockProvider::text_response("b", 10, 5),
344 MockProvider::text_response("c", 10, 5),
345 ]));
346 let agent = make_agent(provider, "test");
347 let executor = BatchExecutor::builder(agent)
348 .max_concurrency(3)
349 .build()
350 .unwrap();
351
352 let tasks = vec!["t0".to_string(), "t1".to_string(), "t2".to_string()];
353 let results = executor.execute(tasks).await;
354
355 assert_eq!(results.len(), 3);
356 for (i, r) in results.iter().enumerate() {
357 assert_eq!(r.index, i);
358 }
359 }
360
361 #[tokio::test]
362 async fn partial_failure_returns_all_results() {
363 let provider = Arc::new(MockProvider::new(vec![
365 MockProvider::text_response("ok1", 10, 5),
366 MockProvider::text_response("ok2", 20, 10),
367 ]));
368 let agent = make_agent(provider, "test");
369 let executor = BatchExecutor::builder(agent)
371 .max_concurrency(1)
372 .build()
373 .unwrap();
374
375 let tasks = vec![
376 "task0".to_string(),
377 "task1".to_string(),
378 "task2".to_string(),
379 ];
380 let results = executor.execute(tasks).await;
381
382 assert_eq!(results.len(), 3);
383 assert!(results[0].result.is_ok());
385 assert!(results[1].result.is_ok());
386 assert!(results[2].result.is_err());
387 }
388
389 #[tokio::test]
390 async fn concurrency_limit_respected() {
391 let current = Arc::new(AtomicUsize::new(0));
392 let peak = Arc::new(AtomicUsize::new(0));
393
394 let provider = Arc::new(ConcurrencyTrackingProvider::new(
395 Arc::clone(¤t),
396 Arc::clone(&peak),
397 "done",
398 ));
399 let agent = AgentRunner::builder(provider)
400 .name("conc-test")
401 .system_prompt("test")
402 .max_turns(1)
403 .build()
404 .expect("build agent");
405
406 let executor = BatchExecutor::builder(agent)
407 .max_concurrency(2)
408 .build()
409 .unwrap();
410
411 let tasks: Vec<String> = (0..10).map(|i| format!("task{i}")).collect();
412 let results = executor.execute(tasks).await;
413
414 assert_eq!(results.len(), 10);
415 let observed_peak = peak.load(Ordering::SeqCst);
417 assert!(
418 observed_peak <= 2,
419 "peak concurrency was {observed_peak}, expected <= 2"
420 );
421 }
422
423 #[tokio::test]
424 async fn aggregate_usage_sums_successes() {
425 let provider = Arc::new(MockProvider::new(vec![
426 MockProvider::text_response("a", 100, 50),
427 MockProvider::text_response("b", 200, 80),
428 ]));
429 let agent = make_agent(provider, "test");
430 let executor = BatchExecutor::builder(agent)
431 .max_concurrency(1)
432 .build()
433 .unwrap();
434
435 let results = executor
436 .execute(vec!["t1".to_string(), "t2".to_string()])
437 .await;
438
439 let usage = BatchExecutor::<MockProvider>::aggregate_usage(&results);
440 assert_eq!(usage.input_tokens, 300);
441 assert_eq!(usage.output_tokens, 130);
442 }
443
444 #[tokio::test]
445 async fn aggregate_usage_ignores_failures() {
446 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
447 "ok", 100, 50,
448 )]));
449 let agent = make_agent(provider, "test");
450 let executor = BatchExecutor::builder(agent)
451 .max_concurrency(1)
452 .build()
453 .unwrap();
454
455 let results = executor
457 .execute(vec!["t1".to_string(), "t2".to_string()])
458 .await;
459
460 let usage = BatchExecutor::<MockProvider>::aggregate_usage(&results);
461 assert_eq!(usage.input_tokens, 100);
463 assert_eq!(usage.output_tokens, 50);
464 }
465
466 #[tokio::test]
467 async fn execute_ref_convenience() {
468 let provider = Arc::new(MockProvider::new(vec![
469 MockProvider::text_response("a", 10, 5),
470 MockProvider::text_response("b", 10, 5),
471 ]));
472 let agent = make_agent(provider, "test");
473 let executor = BatchExecutor::builder(agent)
474 .max_concurrency(2)
475 .build()
476 .unwrap();
477
478 let results = executor.execute_ref(&["hello", "world"]).await;
479 assert_eq!(results.len(), 2);
480 assert_eq!(results[0].input, "hello");
481 assert_eq!(results[1].input, "world");
482 }
483
484 #[test]
485 fn aggregate_usage_empty_results() {
486 let usage = BatchExecutor::<MockProvider>::aggregate_usage(&[]);
487 assert_eq!(usage, TokenUsage::default());
488 }
489}