Skip to main content

mecha_core/
batch.rs

1//! Run the same agent over many inputs.
2//!
3//! This is the eval/sweep shape: N independent prompts, bounded concurrency,
4//! failures recorded rather than fatal, results keyed so they can be joined
5//! back to their inputs in any order.
6
7use 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/// What to say to the agent: one turn, or several in one conversation.
14///
15/// Untagged so `"prompt": "..."` and `"prompt": ["...", "..."]` both parse, and
16/// no existing case or batch file has to change. Several turns share one
17/// `Conversation`, which is the whole point — a turn boundary is not a security
18/// boundary, and anything that only goes wrong across turns (taint
19/// accumulating, a transcript growing past the compaction threshold) cannot be
20/// expressed by a single prompt at all.
21#[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    /// The opening turn, for logs and titles.
37    pub fn first(&self) -> &str {
38        self.turns().first().map(String::as_str).unwrap_or_default()
39    }
40
41    /// Every turn as one readable block, for a judge or a log.
42    ///
43    /// A judge shown only the last turn of a multi-turn case would grade the
44    /// answer against half the question.
45    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    /// Caller-supplied key. Results are matched on this, never on position.
73    pub id: String,
74    pub prompt: Prompt,
75    /// Carried through to the result untouched — useful for gold answers,
76    /// subject ids, or whatever the caller is joining against.
77    #[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    /// What the model actually did. Grading tool use needs this, not `text`.
95    #[serde(default)]
96    pub tool_calls: Vec<ToolCallTrace>,
97    #[serde(default)]
98    pub malformed_tool_args: u32,
99    /// Why the loop stopped, as distinct from why the model stopped talking.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub stop_cause: Option<StopCause>,
102    /// What had entered the conversation by the end.
103    #[serde(default)]
104    pub taint: Taint,
105    /// Outbound calls the interlock refused.
106    #[serde(default)]
107    pub blocked_sends: u32,
108    /// How many times the transcript was summarised.
109    #[serde(default)]
110    pub compactions: u32,
111    /// False when `usage` is a lower bound — see [`crate::agent::RunOutcome`].
112    #[serde(default)]
113    pub usage_complete: bool,
114}
115
116/// Run every item, at most `concurrency` at a time.
117///
118/// `on_result` fires as each item finishes, so a caller can stream progress
119/// instead of waiting for the whole batch.
120pub async fn run<F>(
121    agent: &Agent,
122    items: Vec<BatchItem>,
123    concurrency: usize,
124    on_result: F,
125) -> Vec<BatchResult>
126where
127    F: FnMut(&BatchResult),
128{
129    run_with(agent, items, concurrency, |_| None, on_result).await
130}
131
132/// As [`run`], but each item may be given its own [`RunContext`].
133///
134/// Returning `None` from `context_for` uses the agent's own. This is what makes
135/// a batch of *mutating* items possible: hand each one a private workspace and
136/// the permission to write to it, and they stop being able to see each other's
137/// side effects.
138pub async fn run_with<C, F>(
139    agent: &Agent,
140    items: Vec<BatchItem>,
141    concurrency: usize,
142    context_for: C,
143    mut on_result: F,
144) -> Vec<BatchResult>
145where
146    C: Fn(&BatchItem) -> Option<Arc<RunContext>> + Sync,
147    F: FnMut(&BatchResult),
148{
149    let concurrency = concurrency.max(1);
150    let context_for = &context_for;
151
152    let mut stream = futures::stream::iter(items.into_iter().map(|item| async move {
153        let started = std::time::Instant::now();
154        // Each item gets a fresh conversation — batch items are independent by
155        // definition, and sharing history would leak one into the next. That
156        // now covers the taint as well: one item reading a hostile page must
157        // not arm the interlock for the next, which is a different
158        // conversation that never saw it.
159        let mut convo = Conversation::new();
160        let cx = context_for(&item).unwrap_or_else(|| Arc::clone(agent.context()));
161
162        // Every turn runs on the *same* conversation, so taint accumulates and
163        // the transcript grows exactly as it would in a real session. Totals
164        // are summed across turns; the last turn's answer is the answer.
165        let mut totals = Totals::default();
166        let mut failure = None;
167        let mut last: Option<crate::agent::RunOutcome> = None;
168
169        for turn in item.prompt.turns() {
170            convo.push(Message::user(turn.clone()));
171            match agent.run_in(&cx, &mut convo, None).await {
172                Ok(outcome) => {
173                    totals.absorb(&outcome);
174                    last = Some(outcome);
175                }
176                Err(e) => {
177                    // Stop here: later turns were written to follow this one,
178                    // and running them against a conversation missing a reply
179                    // would measure something nobody asked for.
180                    failure = Some(format!("{e:#}"));
181                    break;
182                }
183            }
184        }
185
186        let elapsed_ms = started.elapsed().as_millis() as u64;
187
188        match (last, failure) {
189            (Some(outcome), None) => BatchResult {
190                id: item.id,
191                // An exhausted run technically returned, but the answer is
192                // truncated; callers shouldn't count it as a success.
193                ok: !outcome.exhausted
194                    && outcome.stop_reason != StopReason::Refusal
195                    && totals.malformed_tool_args == 0,
196                text: outcome.text,
197                error: outcome.refusal.map(|r| {
198                    format!(
199                        "refused ({}): {}",
200                        r.category.unwrap_or_else(|| "unspecified".into()),
201                        r.explanation.unwrap_or_default()
202                    )
203                }),
204                turns: totals.turns,
205                usage: totals.usage,
206                stop_reason: Some(outcome.stop_reason),
207                meta: item.meta,
208                elapsed_ms,
209                tool_calls: totals.tool_calls,
210                malformed_tool_args: totals.malformed_tool_args,
211                stop_cause: Some(outcome.stop_cause),
212                // Taint lives on the conversation, so it is already cumulative.
213                taint: convo.taint,
214                blocked_sends: totals.blocked_sends,
215                compactions: totals.compactions,
216                usage_complete: totals.usage_complete,
217            },
218            // A failure keeps whatever the earlier turns cost: they ran, and a
219            // sweep that under-reports its own spend is worse than one that
220            // reports a failure.
221            (_, error) => BatchResult {
222                id: item.id,
223                ok: false,
224                text: String::new(),
225                error: error.or_else(|| Some("the item had no prompts".into())),
226                turns: totals.turns,
227                usage: totals.usage,
228                stop_reason: None,
229                meta: item.meta,
230                elapsed_ms,
231                tool_calls: totals.tool_calls,
232                malformed_tool_args: totals.malformed_tool_args,
233                stop_cause: None,
234                taint: convo.taint,
235                blocked_sends: totals.blocked_sends,
236                compactions: totals.compactions,
237                usage_complete: totals.usage_complete,
238            },
239        }
240    }))
241    .buffer_unordered(concurrency);
242
243    let mut results = Vec::new();
244    while let Some(result) = stream.next().await {
245        on_result(&result);
246        results.push(result);
247    }
248    results
249}
250
251/// Running totals across the turns of one item.
252///
253/// A multi-turn item is still *one* result, so everything countable is summed
254/// rather than overwritten. Reporting only the last turn would make a two-turn
255/// case look cheaper than a one-turn case that did the same work.
256struct Totals {
257    usage: Usage,
258    turns: u32,
259    tool_calls: Vec<ToolCallTrace>,
260    malformed_tool_args: u32,
261    blocked_sends: u32,
262    compactions: u32,
263    usage_complete: bool,
264}
265
266impl Default for Totals {
267    fn default() -> Self {
268        Totals {
269            usage: Usage::default(),
270            turns: 0,
271            tool_calls: Vec::new(),
272            malformed_tool_args: 0,
273            blocked_sends: 0,
274            compactions: 0,
275            // True until a turn says otherwise: one incomplete count makes the
276            // whole item's total a lower bound.
277            usage_complete: true,
278        }
279    }
280}
281
282impl Totals {
283    fn absorb(&mut self, outcome: &crate::agent::RunOutcome) {
284        self.usage.add(&outcome.usage);
285        self.turns += outcome.turns;
286        self.tool_calls.extend(outcome.tool_calls.iter().cloned());
287        self.malformed_tool_args += outcome.malformed_tool_args;
288        self.blocked_sends += outcome.blocked_sends;
289        self.compactions += outcome.compactions;
290        self.usage_complete &= outcome.usage_complete;
291    }
292}
293
294/// Totals for a finished batch.
295#[derive(Debug, Clone, Serialize)]
296pub struct BatchSummary {
297    pub total: usize,
298    pub succeeded: usize,
299    pub failed: usize,
300    pub usage: Usage,
301    pub elapsed_ms: u64,
302}
303
304impl BatchSummary {
305    pub fn of(results: &[BatchResult], elapsed_ms: u64) -> Self {
306        let mut usage = Usage::default();
307        for r in results {
308            usage.add(&r.usage);
309        }
310        let succeeded = results.iter().filter(|r| r.ok).count();
311        BatchSummary {
312            total: results.len(),
313            succeeded,
314            failed: results.len() - succeeded,
315            usage,
316            elapsed_ms,
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::config::{AgentConfig, PermissionMode};
325    use crate::message::{Block, CompletionRequest, CompletionResponse, Message};
326    use crate::provider::{Provider, StreamSink};
327    use crate::tool::{ModeApprover, Registry, ToolCtx};
328    use anyhow::Result;
329    use async_trait::async_trait;
330    use std::sync::atomic::{AtomicUsize, Ordering};
331    use std::sync::Mutex;
332
333    /// Answers from the *request* rather than from a queue.
334    ///
335    /// A scripted list of turns is useless here: `buffer_unordered` means the
336    /// order items reach the provider is not the order they were submitted, so
337    /// a shared queue would hand item B the answer meant for item A and the
338    /// test would be measuring its own fixture.
339    #[derive(Default)]
340    struct EchoProvider {
341        /// How many messages each request carried. One per item, if each item
342        /// really does get its own conversation.
343        history_lengths: Mutex<Vec<usize>>,
344        in_flight: AtomicUsize,
345        max_in_flight: AtomicUsize,
346        delay: std::time::Duration,
347    }
348
349    #[async_trait]
350    impl Provider for EchoProvider {
351        fn id(&self) -> &str {
352            "echo"
353        }
354        fn default_model(&self) -> &str {
355            "echo-1"
356        }
357
358        async fn complete(
359            &self,
360            req: &CompletionRequest,
361            _sink: Option<&StreamSink>,
362        ) -> Result<CompletionResponse> {
363            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
364            self.max_in_flight.fetch_max(now, Ordering::SeqCst);
365            if !self.delay.is_zero() {
366                tokio::time::sleep(self.delay).await;
367            }
368            self.in_flight.fetch_sub(1, Ordering::SeqCst);
369
370            self.history_lengths
371                .lock()
372                .unwrap()
373                .push(req.messages.len());
374            let prompt = req.messages.last().map(|m| m.text()).unwrap_or_default();
375
376            // One item is allowed to blow up, so the "recorded, not fatal"
377            // behaviour has something to record.
378            anyhow::ensure!(!prompt.contains("boom"), "the provider exploded");
379
380            Ok(CompletionResponse {
381                message: Message::assistant(vec![Block::text(format!("answered: {prompt}"))]),
382                stop_reason: StopReason::EndTurn,
383                usage: Usage {
384                    input_tokens: 10,
385                    output_tokens: 5,
386                    ..Usage::default()
387                },
388                refusal: None,
389                model: "echo-1".into(),
390                malformed_tool_args: 0,
391            })
392        }
393    }
394
395    fn agent_with(provider: Arc<EchoProvider>) -> Agent {
396        struct Shared(Arc<EchoProvider>);
397        #[async_trait]
398        impl Provider for Shared {
399            fn id(&self) -> &str {
400                self.0.id()
401            }
402            fn default_model(&self) -> &str {
403                self.0.default_model()
404            }
405            async fn complete(
406                &self,
407                req: &CompletionRequest,
408                sink: Option<&StreamSink>,
409            ) -> Result<CompletionResponse> {
410                self.0.complete(req, sink).await
411            }
412        }
413
414        Agent::new(
415            Box::new(Shared(provider)),
416            Registry::new(),
417            Arc::new(ModeApprover {
418                mode: PermissionMode::Allow,
419            }),
420            ToolCtx {
421                workspace: std::env::temp_dir(),
422                ..Default::default()
423            },
424            AgentConfig::default(),
425            None,
426        )
427        .unwrap()
428    }
429
430    fn items(prompts: &[&str]) -> Vec<BatchItem> {
431        prompts
432            .iter()
433            .enumerate()
434            .map(|(i, p)| BatchItem {
435                id: format!("item-{i}"),
436                prompt: (*p).to_string().into(),
437                meta: Some(serde_json::json!({"index": i})),
438            })
439            .collect()
440    }
441
442    #[tokio::test]
443    async fn results_are_matched_by_id_not_by_position() {
444        let provider = Arc::new(EchoProvider {
445            delay: std::time::Duration::from_millis(20),
446            ..Default::default()
447        });
448        let agent = agent_with(Arc::clone(&provider));
449
450        let results = run(
451            &agent,
452            items(&["alpha", "beta", "gamma", "delta"]),
453            4,
454            |_| {},
455        )
456        .await;
457
458        // Completion order under concurrency is not submission order, so every
459        // result has to carry its own key and metadata home with it.
460        assert_eq!(results.len(), 4);
461        for r in &results {
462            let index = r.meta.as_ref().unwrap()["index"].as_u64().unwrap();
463            assert_eq!(r.id, format!("item-{index}"));
464            let expected = ["alpha", "beta", "gamma", "delta"][index as usize];
465            assert_eq!(
466                r.text,
467                format!("answered: {expected}"),
468                "{} got another item's answer",
469                r.id
470            );
471        }
472    }
473
474    #[tokio::test]
475    async fn every_item_gets_its_own_conversation() {
476        let provider = Arc::new(EchoProvider::default());
477        let agent = agent_with(Arc::clone(&provider));
478
479        run(&agent, items(&["one", "two", "three"]), 1, |_| {}).await;
480
481        // Batch items are independent by definition. A shared conversation
482        // would grow, and — since taint travels with the messages — one item
483        // reading a hostile page would arm the interlock for every item after
484        // it.
485        let lengths = provider.history_lengths.lock().unwrap();
486        assert_eq!(*lengths, vec![1, 1, 1], "history leaked between items");
487    }
488
489    #[tokio::test]
490    async fn a_failing_item_is_recorded_rather_than_sinking_the_batch() {
491        let provider = Arc::new(EchoProvider::default());
492        let agent = agent_with(Arc::clone(&provider));
493
494        let results = run(&agent, items(&["fine", "boom", "also fine"]), 1, |_| {}).await;
495
496        assert_eq!(results.len(), 3, "a failure took other items down with it");
497        let failed: Vec<_> = results.iter().filter(|r| !r.ok).collect();
498        assert_eq!(failed.len(), 1);
499        assert_eq!(failed[0].id, "item-1");
500        assert!(failed[0].error.as_ref().unwrap().contains("exploded"));
501        // A failure still reports its key and metadata, or it cannot be joined
502        // back to the input that caused it.
503        assert!(failed[0].meta.is_some());
504
505        assert!(results.iter().filter(|r| r.ok).count() == 2);
506    }
507
508    #[tokio::test]
509    async fn concurrency_is_bounded_by_what_was_asked_for() {
510        let provider = Arc::new(EchoProvider {
511            delay: std::time::Duration::from_millis(50),
512            ..Default::default()
513        });
514        let agent = agent_with(Arc::clone(&provider));
515
516        run(&agent, items(&["a", "b", "c", "d", "e", "f"]), 2, |_| {}).await;
517
518        let peak = provider.max_in_flight.load(Ordering::SeqCst);
519        assert!(peak <= 2, "{peak} items ran at once against a limit of 2");
520        assert_eq!(
521            peak, 2,
522            "the limit was never actually reached; the test proves nothing"
523        );
524    }
525
526    #[tokio::test]
527    async fn a_concurrency_of_zero_still_makes_progress() {
528        let provider = Arc::new(EchoProvider::default());
529        let agent = agent_with(Arc::clone(&provider));
530
531        // `concurrency.max(1)`: a zero would otherwise mean a stream that never
532        // polls anything and a batch that hangs forever.
533        let results = run(&agent, items(&["only"]), 0, |_| {}).await;
534        assert_eq!(results.len(), 1);
535        assert!(results[0].ok);
536    }
537
538    #[tokio::test]
539    async fn each_result_is_announced_as_it_lands() {
540        let provider = Arc::new(EchoProvider::default());
541        let agent = agent_with(Arc::clone(&provider));
542
543        // The callback is what lets a long eval print progress instead of
544        // going quiet for ten minutes.
545        let mut announced = Vec::new();
546        let results = run(&agent, items(&["a", "b", "c"]), 1, |r| {
547            announced.push(r.id.clone())
548        })
549        .await;
550
551        assert_eq!(announced.len(), 3);
552        assert_eq!(
553            announced,
554            results.iter().map(|r| r.id.clone()).collect::<Vec<_>>()
555        );
556    }
557
558    #[test]
559    fn a_summary_totals_usage_and_counts_both_outcomes() {
560        let result = |id: &str, ok: bool| BatchResult {
561            id: id.into(),
562            ok,
563            text: String::new(),
564            error: None,
565            turns: 1,
566            usage: Usage {
567                input_tokens: 10,
568                output_tokens: 5,
569                ..Usage::default()
570            },
571            stop_reason: Some(StopReason::EndTurn),
572            meta: None,
573            elapsed_ms: 1,
574            tool_calls: Vec::new(),
575            malformed_tool_args: 0,
576            stop_cause: None,
577            taint: Taint::default(),
578            blocked_sends: 0,
579            compactions: 0,
580            usage_complete: true,
581        };
582
583        let summary = BatchSummary::of(
584            &[result("a", true), result("b", false), result("c", true)],
585            99,
586        );
587
588        assert_eq!(summary.total, 3);
589        assert_eq!(summary.succeeded, 2);
590        assert_eq!(summary.failed, 1);
591        // Failed items still burned tokens, and a summary that hid them would
592        // under-report what a sweep cost.
593        assert_eq!(summary.usage.input_tokens, 30);
594        assert_eq!(summary.usage.output_tokens, 15);
595        assert_eq!(summary.elapsed_ms, 99);
596    }
597}