Skip to main content

heartbit_core/agent/
batch.rs

1//! Batch executor for running the same agent on multiple tasks with controlled concurrency.
2//!
3//! Unlike [`ParallelAgent`](super::workflow::ParallelAgent) which runs different agents on the
4//! same task, `BatchExecutor` runs the **same agent** on different tasks with a concurrency limit
5//! via [`tokio::sync::Semaphore`].
6
7use 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/// Result of a single batch item execution.
20#[derive(Debug)]
21pub struct BatchResult {
22    /// Index of this item in the original input batch.
23    pub index: usize,
24    /// The input task that was executed.
25    pub input: String,
26    /// The execution result (Ok with output, or Err).
27    pub result: Result<AgentOutput, Error>,
28}
29
30/// Configuration for batch execution.
31#[derive(Debug, Clone)]
32pub struct BatchConfig {
33    /// Maximum number of concurrent agent executions.
34    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
47/// Executes multiple tasks through an agent with controlled concurrency.
48///
49/// Unlike `ParallelAgent` which runs different agents on the same task,
50/// `BatchExecutor` runs the SAME agent on different tasks with a concurrency limit.
51pub 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
64/// Builder for [`BatchExecutor`].
65pub struct BatchExecutorBuilder<P: LlmProvider + 'static> {
66    agent: AgentRunner<P>,
67    max_concurrency: Option<usize>,
68}
69
70impl<P: LlmProvider + 'static> BatchExecutor<P> {
71    /// Create a new builder for `BatchExecutor`.
72    pub fn builder(agent: AgentRunner<P>) -> BatchExecutorBuilder<P> {
73        BatchExecutorBuilder {
74            agent,
75            max_concurrency: None,
76        }
77    }
78
79    /// Execute all tasks with controlled concurrency.
80    /// Returns results for ALL tasks (successes and failures).
81    /// Results are sorted by input index.
82    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                    // JoinSet task panicked — should not happen in normal operation.
110                    // We can't recover the index/input, so we skip it.
111                    // In practice, agent.execute() should not panic.
112                    tracing::error!("batch task panicked: {e}");
113                }
114            }
115        }
116
117        results.sort_by_key(|r| r.index);
118        results
119    }
120
121    /// Convenience: execute with string slice references.
122    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    /// Returns aggregate token usage across all successful executions.
128    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    /// Set the maximum number of concurrent agent executions.
141    pub fn max_concurrency(mut self, n: usize) -> Self {
142        self.max_concurrency = Some(n);
143        self
144    }
145
146    /// Build the [`BatchExecutor`].
147    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// ===========================================================================
167// Tests
168// ===========================================================================
169
170#[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    /// A mock provider that tracks concurrency via an atomic counter.
178    struct ConcurrencyTrackingProvider {
179        /// Current number of concurrent executions.
180        current: Arc<AtomicUsize>,
181        /// Peak concurrency observed.
182        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            // Update peak
201            self.peak.fetch_max(concurrent, Ordering::SeqCst);
202            // Simulate some work
203            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    // -----------------------------------------------------------------------
227    // Builder tests
228    // -----------------------------------------------------------------------
229
230    #[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    // -----------------------------------------------------------------------
278    // Execution tests
279    // -----------------------------------------------------------------------
280
281    #[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        // Provide only 2 responses for 3 tasks — third will fail
364        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        // max_concurrency=1 to get deterministic ordering of mock responses
370        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        // First two succeed, third fails
384        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(&current),
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        // Peak concurrency should not exceed 2
416        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        // 2 tasks but only 1 response — second fails
456        let results = executor
457            .execute(vec!["t1".to_string(), "t2".to_string()])
458            .await;
459
460        let usage = BatchExecutor::<MockProvider>::aggregate_usage(&results);
461        // Only the first task's usage
462        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}