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    /// The item's *last* run finished on a failed tool call — see
115    /// [`crate::agent::RunOutcome::ended_on_failed_call`].
116    ///
117    /// Not summed into [`Totals`] like everything else here, because it is a
118    /// property of how the item ended rather than of what it cost: a two-turn
119    /// item whose first turn ended on a failure and whose second recovered has
120    /// not finished over a failure, and summing would say it had.
121    #[serde(default)]
122    pub ended_on_failed_call: bool,
123}
124
125/// Run every item, at most `concurrency` at a time.
126///
127/// `on_result` fires as each item finishes, so a caller can stream progress
128/// instead of waiting for the whole batch.
129pub 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
141/// As [`run`], but each item may be given its own [`RunContext`].
142///
143/// Returning `None` from `context_for` uses the agent's own. This is what makes
144/// a batch of *mutating* items possible: hand each one a private workspace and
145/// the permission to write to it, and they stop being able to see each other's
146/// side effects.
147pub 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        // Each item gets a fresh conversation — batch items are independent by
164        // definition, and sharing history would leak one into the next. That
165        // now covers the taint as well: one item reading a hostile page must
166        // not arm the interlock for the next, which is a different
167        // conversation that never saw it.
168        // A fresh conversation, so anything scoped to the previous item's
169        // must go with it. One agent serves every item here, so a `skill`
170        // loaded by item 3 would otherwise narrow item 4's tool surface and
171        // splice item 3's procedure into item 4's compaction.
172        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        // Every turn runs on the *same* conversation, so taint accumulates and
177        // the transcript grows exactly as it would in a real session. Totals
178        // are summed across turns; the last turn's answer is the answer.
179        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                    // Stop here: later turns were written to follow this one,
192                    // and running them against a conversation missing a reply
193                    // would measure something nobody asked for.
194                    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                // An exhausted run technically returned, but the answer is
206                // truncated; callers shouldn't count it as a success.
207                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 lives on the conversation, so it is already cumulative.
227                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            // A failure keeps whatever the earlier turns cost: they ran, and a
234            // sweep that under-reports its own spend is worse than one that
235            // reports a failure.
236            (_, 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                // No run reached an outcome, so there is nothing to observe.
254                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
268/// Running totals across the turns of one item.
269///
270/// A multi-turn item is still *one* result, so everything countable is summed
271/// rather than overwritten. Reporting only the last turn would make a two-turn
272/// case look cheaper than a one-turn case that did the same work.
273struct 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            // True until a turn says otherwise: one incomplete count makes the
293            // whole item's total a lower bound.
294            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/// Totals for a finished batch.
312#[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    /// Answers from the *request* rather than from a queue.
351    ///
352    /// A scripted list of turns is useless here: `buffer_unordered` means the
353    /// order items reach the provider is not the order they were submitted, so
354    /// a shared queue would hand item B the answer meant for item A and the
355    /// test would be measuring its own fixture.
356    #[derive(Default)]
357    struct EchoProvider {
358        /// How many messages each request carried. One per item, if each item
359        /// really does get its own conversation.
360        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            // One item is allowed to blow up, so the "recorded, not fatal"
394            // behaviour has something to record.
395            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        // Completion order under concurrency is not submission order, so every
476        // result has to carry its own key and metadata home with it.
477        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        // Batch items are independent by definition. A shared conversation
499        // would grow, and — since taint travels with the messages — one item
500        // reading a hostile page would arm the interlock for every item after
501        // it.
502        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        // A failure still reports its key and metadata, or it cannot be joined
519        // back to the input that caused it.
520        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        // `concurrency.max(1)`: a zero would otherwise mean a stream that never
549        // polls anything and a batch that hangs forever.
550        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        // The callback is what lets a long eval print progress instead of
561        // going quiet for ten minutes.
562        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        // Failed items still burned tokens, and a summary that hid them would
610        // under-report what a sweep cost.
611        assert_eq!(summary.usage.input_tokens, 30);
612        assert_eq!(summary.usage.output_tokens, 15);
613        assert_eq!(summary.elapsed_ms, 99);
614    }
615}