1use crate::agent::{Agent, Conversation, RunContext, StopCause, Taint, ToolCallTrace};
8use crate::message::{Message, StopReason, Usage};
9use futures::stream::StreamExt;
10use serde::{Deserialize, Serialize};
11use std::sync::Arc;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(untagged)]
23pub enum Prompt {
24 One(String),
25 Many(Vec<String>),
26}
27
28impl Prompt {
29 pub fn turns(&self) -> &[String] {
30 match self {
31 Prompt::One(s) => std::slice::from_ref(s),
32 Prompt::Many(v) => v,
33 }
34 }
35
36 pub fn first(&self) -> &str {
38 self.turns().first().map(String::as_str).unwrap_or_default()
39 }
40
41 pub fn render(&self) -> String {
46 match self {
47 Prompt::One(s) => s.clone(),
48 Prompt::Many(v) => v
49 .iter()
50 .enumerate()
51 .map(|(i, t)| format!("[turn {}] {t}", i + 1))
52 .collect::<Vec<_>>()
53 .join("\n"),
54 }
55 }
56}
57
58impl From<String> for Prompt {
59 fn from(s: String) -> Self {
60 Prompt::One(s)
61 }
62}
63
64impl From<&str> for Prompt {
65 fn from(s: &str) -> Self {
66 Prompt::One(s.to_string())
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct BatchItem {
72 pub id: String,
74 pub prompt: Prompt,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub meta: Option<serde_json::Value>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct BatchResult {
83 pub id: String,
84 pub ok: bool,
85 pub text: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub error: Option<String>,
88 pub turns: u32,
89 pub usage: Usage,
90 pub stop_reason: Option<StopReason>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub meta: Option<serde_json::Value>,
93 pub elapsed_ms: u64,
94 #[serde(default)]
96 pub tool_calls: Vec<ToolCallTrace>,
97 #[serde(default)]
98 pub malformed_tool_args: u32,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub stop_cause: Option<StopCause>,
102 #[serde(default)]
104 pub taint: Taint,
105 #[serde(default)]
107 pub blocked_sends: u32,
108 #[serde(default)]
110 pub compactions: u32,
111 #[serde(default)]
113 pub usage_complete: bool,
114 #[serde(default)]
122 pub ended_on_failed_call: bool,
123}
124
125pub async fn run<F>(
130 agent: &Agent,
131 items: Vec<BatchItem>,
132 concurrency: usize,
133 on_result: F,
134) -> Vec<BatchResult>
135where
136 F: FnMut(&BatchResult),
137{
138 run_with(agent, items, concurrency, |_| None, on_result).await
139}
140
141pub async fn run_with<C, F>(
148 agent: &Agent,
149 items: Vec<BatchItem>,
150 concurrency: usize,
151 context_for: C,
152 mut on_result: F,
153) -> Vec<BatchResult>
154where
155 C: Fn(&BatchItem) -> Option<Arc<RunContext>> + Sync,
156 F: FnMut(&BatchResult),
157{
158 let concurrency = concurrency.max(1);
159 let context_for = &context_for;
160
161 let mut stream = futures::stream::iter(items.into_iter().map(|item| async move {
162 let started = std::time::Instant::now();
163 agent.registry().forget_conversation_state();
173 let mut convo = Conversation::new();
174 let cx = context_for(&item).unwrap_or_else(|| Arc::clone(agent.context()));
175
176 let mut totals = Totals::default();
180 let mut failure = None;
181 let mut last: Option<crate::agent::RunOutcome> = None;
182
183 for turn in item.prompt.turns() {
184 convo.push(Message::user(turn.clone()));
185 match agent.run_in(&cx, &mut convo, None).await {
186 Ok(outcome) => {
187 totals.absorb(&outcome);
188 last = Some(outcome);
189 }
190 Err(e) => {
191 failure = Some(format!("{e:#}"));
195 break;
196 }
197 }
198 }
199
200 let elapsed_ms = started.elapsed().as_millis() as u64;
201
202 match (last, failure) {
203 (Some(outcome), None) => BatchResult {
204 id: item.id,
205 ok: !outcome.exhausted
208 && outcome.stop_reason != StopReason::Refusal
209 && totals.malformed_tool_args == 0,
210 text: outcome.text,
211 error: outcome.refusal.map(|r| {
212 format!(
213 "refused ({}): {}",
214 r.category.unwrap_or_else(|| "unspecified".into()),
215 r.explanation.unwrap_or_default()
216 )
217 }),
218 turns: totals.turns,
219 usage: totals.usage,
220 stop_reason: Some(outcome.stop_reason),
221 meta: item.meta,
222 elapsed_ms,
223 tool_calls: totals.tool_calls,
224 malformed_tool_args: totals.malformed_tool_args,
225 stop_cause: Some(outcome.stop_cause),
226 taint: convo.taint,
228 blocked_sends: totals.blocked_sends,
229 compactions: totals.compactions,
230 usage_complete: totals.usage_complete,
231 ended_on_failed_call: outcome.ended_on_failed_call,
232 },
233 (_, error) => BatchResult {
237 id: item.id,
238 ok: false,
239 text: String::new(),
240 error: error.or_else(|| Some("the item had no prompts".into())),
241 turns: totals.turns,
242 usage: totals.usage,
243 stop_reason: None,
244 meta: item.meta,
245 elapsed_ms,
246 tool_calls: totals.tool_calls,
247 malformed_tool_args: totals.malformed_tool_args,
248 stop_cause: None,
249 taint: convo.taint,
250 blocked_sends: totals.blocked_sends,
251 compactions: totals.compactions,
252 usage_complete: totals.usage_complete,
253 ended_on_failed_call: false,
255 },
256 }
257 }))
258 .buffer_unordered(concurrency);
259
260 let mut results = Vec::new();
261 while let Some(result) = stream.next().await {
262 on_result(&result);
263 results.push(result);
264 }
265 results
266}
267
268struct Totals {
274 usage: Usage,
275 turns: u32,
276 tool_calls: Vec<ToolCallTrace>,
277 malformed_tool_args: u32,
278 blocked_sends: u32,
279 compactions: u32,
280 usage_complete: bool,
281}
282
283impl Default for Totals {
284 fn default() -> Self {
285 Totals {
286 usage: Usage::default(),
287 turns: 0,
288 tool_calls: Vec::new(),
289 malformed_tool_args: 0,
290 blocked_sends: 0,
291 compactions: 0,
292 usage_complete: true,
295 }
296 }
297}
298
299impl Totals {
300 fn absorb(&mut self, outcome: &crate::agent::RunOutcome) {
301 self.usage.add(&outcome.usage);
302 self.turns += outcome.turns;
303 self.tool_calls.extend(outcome.tool_calls.iter().cloned());
304 self.malformed_tool_args += outcome.malformed_tool_args;
305 self.blocked_sends += outcome.blocked_sends;
306 self.compactions += outcome.compactions;
307 self.usage_complete &= outcome.usage_complete;
308 }
309}
310
311#[derive(Debug, Clone, Serialize)]
313pub struct BatchSummary {
314 pub total: usize,
315 pub succeeded: usize,
316 pub failed: usize,
317 pub usage: Usage,
318 pub elapsed_ms: u64,
319}
320
321impl BatchSummary {
322 pub fn of(results: &[BatchResult], elapsed_ms: u64) -> Self {
323 let mut usage = Usage::default();
324 for r in results {
325 usage.add(&r.usage);
326 }
327 let succeeded = results.iter().filter(|r| r.ok).count();
328 BatchSummary {
329 total: results.len(),
330 succeeded,
331 failed: results.len() - succeeded,
332 usage,
333 elapsed_ms,
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::config::{AgentConfig, PermissionMode};
342 use crate::message::{Block, CompletionRequest, CompletionResponse, Message};
343 use crate::provider::{Provider, StreamSink};
344 use crate::tool::{ModeApprover, Registry, ToolCtx};
345 use anyhow::Result;
346 use async_trait::async_trait;
347 use std::sync::atomic::{AtomicUsize, Ordering};
348 use std::sync::Mutex;
349
350 #[derive(Default)]
357 struct EchoProvider {
358 history_lengths: Mutex<Vec<usize>>,
361 in_flight: AtomicUsize,
362 max_in_flight: AtomicUsize,
363 delay: std::time::Duration,
364 }
365
366 #[async_trait]
367 impl Provider for EchoProvider {
368 fn id(&self) -> &str {
369 "echo"
370 }
371 fn default_model(&self) -> &str {
372 "echo-1"
373 }
374
375 async fn complete(
376 &self,
377 req: &CompletionRequest,
378 _sink: Option<&StreamSink>,
379 ) -> Result<CompletionResponse> {
380 let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
381 self.max_in_flight.fetch_max(now, Ordering::SeqCst);
382 if !self.delay.is_zero() {
383 tokio::time::sleep(self.delay).await;
384 }
385 self.in_flight.fetch_sub(1, Ordering::SeqCst);
386
387 self.history_lengths
388 .lock()
389 .unwrap()
390 .push(req.messages.len());
391 let prompt = req.messages.last().map(|m| m.text()).unwrap_or_default();
392
393 anyhow::ensure!(!prompt.contains("boom"), "the provider exploded");
396
397 Ok(CompletionResponse {
398 message: Message::assistant(vec![Block::text(format!("answered: {prompt}"))]),
399 stop_reason: StopReason::EndTurn,
400 usage: Usage {
401 input_tokens: 10,
402 output_tokens: 5,
403 ..Usage::default()
404 },
405 refusal: None,
406 model: "echo-1".into(),
407 malformed_tool_args: 0,
408 })
409 }
410 }
411
412 fn agent_with(provider: Arc<EchoProvider>) -> Agent {
413 struct Shared(Arc<EchoProvider>);
414 #[async_trait]
415 impl Provider for Shared {
416 fn id(&self) -> &str {
417 self.0.id()
418 }
419 fn default_model(&self) -> &str {
420 self.0.default_model()
421 }
422 async fn complete(
423 &self,
424 req: &CompletionRequest,
425 sink: Option<&StreamSink>,
426 ) -> Result<CompletionResponse> {
427 self.0.complete(req, sink).await
428 }
429 }
430
431 Agent::new(
432 Box::new(Shared(provider)),
433 Registry::new(),
434 Arc::new(ModeApprover {
435 mode: PermissionMode::Allow,
436 }),
437 ToolCtx {
438 workspace: std::env::temp_dir(),
439 ..Default::default()
440 },
441 AgentConfig::default(),
442 None,
443 )
444 .unwrap()
445 }
446
447 fn items(prompts: &[&str]) -> Vec<BatchItem> {
448 prompts
449 .iter()
450 .enumerate()
451 .map(|(i, p)| BatchItem {
452 id: format!("item-{i}"),
453 prompt: (*p).to_string().into(),
454 meta: Some(serde_json::json!({"index": i})),
455 })
456 .collect()
457 }
458
459 #[tokio::test]
460 async fn results_are_matched_by_id_not_by_position() {
461 let provider = Arc::new(EchoProvider {
462 delay: std::time::Duration::from_millis(20),
463 ..Default::default()
464 });
465 let agent = agent_with(Arc::clone(&provider));
466
467 let results = run(
468 &agent,
469 items(&["alpha", "beta", "gamma", "delta"]),
470 4,
471 |_| {},
472 )
473 .await;
474
475 assert_eq!(results.len(), 4);
478 for r in &results {
479 let index = r.meta.as_ref().unwrap()["index"].as_u64().unwrap();
480 assert_eq!(r.id, format!("item-{index}"));
481 let expected = ["alpha", "beta", "gamma", "delta"][index as usize];
482 assert_eq!(
483 r.text,
484 format!("answered: {expected}"),
485 "{} got another item's answer",
486 r.id
487 );
488 }
489 }
490
491 #[tokio::test]
492 async fn every_item_gets_its_own_conversation() {
493 let provider = Arc::new(EchoProvider::default());
494 let agent = agent_with(Arc::clone(&provider));
495
496 run(&agent, items(&["one", "two", "three"]), 1, |_| {}).await;
497
498 let lengths = provider.history_lengths.lock().unwrap();
503 assert_eq!(*lengths, vec![1, 1, 1], "history leaked between items");
504 }
505
506 #[tokio::test]
507 async fn a_failing_item_is_recorded_rather_than_sinking_the_batch() {
508 let provider = Arc::new(EchoProvider::default());
509 let agent = agent_with(Arc::clone(&provider));
510
511 let results = run(&agent, items(&["fine", "boom", "also fine"]), 1, |_| {}).await;
512
513 assert_eq!(results.len(), 3, "a failure took other items down with it");
514 let failed: Vec<_> = results.iter().filter(|r| !r.ok).collect();
515 assert_eq!(failed.len(), 1);
516 assert_eq!(failed[0].id, "item-1");
517 assert!(failed[0].error.as_ref().unwrap().contains("exploded"));
518 assert!(failed[0].meta.is_some());
521
522 assert!(results.iter().filter(|r| r.ok).count() == 2);
523 }
524
525 #[tokio::test]
526 async fn concurrency_is_bounded_by_what_was_asked_for() {
527 let provider = Arc::new(EchoProvider {
528 delay: std::time::Duration::from_millis(50),
529 ..Default::default()
530 });
531 let agent = agent_with(Arc::clone(&provider));
532
533 run(&agent, items(&["a", "b", "c", "d", "e", "f"]), 2, |_| {}).await;
534
535 let peak = provider.max_in_flight.load(Ordering::SeqCst);
536 assert!(peak <= 2, "{peak} items ran at once against a limit of 2");
537 assert_eq!(
538 peak, 2,
539 "the limit was never actually reached; the test proves nothing"
540 );
541 }
542
543 #[tokio::test]
544 async fn a_concurrency_of_zero_still_makes_progress() {
545 let provider = Arc::new(EchoProvider::default());
546 let agent = agent_with(Arc::clone(&provider));
547
548 let results = run(&agent, items(&["only"]), 0, |_| {}).await;
551 assert_eq!(results.len(), 1);
552 assert!(results[0].ok);
553 }
554
555 #[tokio::test]
556 async fn each_result_is_announced_as_it_lands() {
557 let provider = Arc::new(EchoProvider::default());
558 let agent = agent_with(Arc::clone(&provider));
559
560 let mut announced = Vec::new();
563 let results = run(&agent, items(&["a", "b", "c"]), 1, |r| {
564 announced.push(r.id.clone())
565 })
566 .await;
567
568 assert_eq!(announced.len(), 3);
569 assert_eq!(
570 announced,
571 results.iter().map(|r| r.id.clone()).collect::<Vec<_>>()
572 );
573 }
574
575 #[test]
576 fn a_summary_totals_usage_and_counts_both_outcomes() {
577 let result = |id: &str, ok: bool| BatchResult {
578 ended_on_failed_call: false,
579 id: id.into(),
580 ok,
581 text: String::new(),
582 error: None,
583 turns: 1,
584 usage: Usage {
585 input_tokens: 10,
586 output_tokens: 5,
587 ..Usage::default()
588 },
589 stop_reason: Some(StopReason::EndTurn),
590 meta: None,
591 elapsed_ms: 1,
592 tool_calls: Vec::new(),
593 malformed_tool_args: 0,
594 stop_cause: None,
595 taint: Taint::default(),
596 blocked_sends: 0,
597 compactions: 0,
598 usage_complete: true,
599 };
600
601 let summary = BatchSummary::of(
602 &[result("a", true), result("b", false), result("c", true)],
603 99,
604 );
605
606 assert_eq!(summary.total, 3);
607 assert_eq!(summary.succeeded, 2);
608 assert_eq!(summary.failed, 1);
609 assert_eq!(summary.usage.input_tokens, 30);
612 assert_eq!(summary.usage.output_tokens, 15);
613 assert_eq!(summary.elapsed_ms, 99);
614 }
615}