1use std::sync::{Arc, Mutex};
18use std::time::Instant;
19
20use crate::agent::events::AgentEvent;
21use crate::execution_context::ExecutionContext;
22use crate::llm::LlmProvider;
23use crate::tool::Tool;
24
25use super::builder::BrowserAgentBuilder;
26
27#[derive(Default)]
32struct RunTrace {
33 turns: usize,
34 tool_calls: usize,
35 tools: Vec<String>,
36 input_tokens: u32,
37 output_tokens: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Oracle {
44 FinalPageContains(String),
47 AgentAnswerContains(String),
50 UrlContains(String),
52}
53
54impl Oracle {
55 pub fn grade(&self, snapshot: &str, answer: &str) -> bool {
58 match self {
59 Oracle::FinalPageContains(s) => snapshot.contains(s.as_str()),
60 Oracle::AgentAnswerContains(s) => answer.to_lowercase().contains(&s.to_lowercase()),
61 Oracle::UrlContains(s) => {
62 snapshot_url(snapshot).is_some_and(|u| u.contains(s.as_str()))
63 }
64 }
65 }
66}
67
68fn snapshot_url(snapshot: &str) -> Option<&str> {
70 let line = snapshot.lines().find(|l| l.contains("RootWebArea"))?;
71 let key = "url=\"";
72 let start = line.find(key)? + key.len();
73 let rest = &line[start..];
74 let end = rest.find('"')?;
75 Some(&rest[..end])
76}
77
78#[derive(Debug, Clone)]
81pub struct BenchTask {
82 pub name: String,
84 pub difficulty: String,
86 pub allow_hosts: Vec<String>,
88 pub instruction: String,
90 pub oracle: Oracle,
92 pub max_turns: usize,
94 pub tools: Vec<String>,
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct BenchResult {
101 pub name: String,
103 pub difficulty: String,
105 pub passed: bool,
107 pub tool_calls: usize,
109 pub input_tokens: u32,
111 pub output_tokens: u32,
113 pub cost_usd: Option<f64>,
115 pub millis: u128,
117 pub attempts: usize,
121 pub turns: usize,
124 pub trace: Vec<String>,
128 pub answer_excerpt: String,
130 pub answer: String,
132 pub final_snapshot: String,
134 pub error: Option<String>,
136}
137
138pub async fn run_bench<P: LlmProvider>(
141 provider: Arc<P>,
142 tools: Vec<Arc<dyn Tool>>,
143 tasks: &[BenchTask],
144) -> Vec<BenchResult> {
145 run_bench_with_retries(provider, tools, tasks, 1).await
146}
147
148pub async fn run_bench_with_retries<P: LlmProvider>(
157 provider: Arc<P>,
158 tools: Vec<Arc<dyn Tool>>,
159 tasks: &[BenchTask],
160 max_attempts: usize,
161) -> Vec<BenchResult> {
162 let ctx = ExecutionContext::default();
163 let snapshot_tool = tools
164 .iter()
165 .find(|t| t.definition().name == "take_snapshot")
166 .cloned();
167 let cap = max_attempts.max(1);
168
169 let mut results = Vec::with_capacity(tasks.len());
170 for task in tasks {
171 let mut r = BenchResult::default();
172 for attempt in 1..=cap {
173 r = run_task_once(&provider, &tools, snapshot_tool.as_ref(), &ctx, task).await;
174 r.attempts = attempt;
175 if r.passed {
176 break;
177 }
178 }
179 results.push(r);
180 }
181 results
182}
183
184async fn run_task_once<P: LlmProvider>(
188 provider: &Arc<P>,
189 tools: &[Arc<dyn Tool>],
190 snapshot_tool: Option<&Arc<dyn Tool>>,
191 ctx: &ExecutionContext,
192 task: &BenchTask,
193) -> BenchResult {
194 let started = Instant::now();
195 let mut r = BenchResult {
196 name: task.name.clone(),
197 difficulty: task.difficulty.clone(),
198 ..BenchResult::default()
199 };
200 let mut answer = String::new();
203
204 let trace = Arc::new(Mutex::new(RunTrace::default()));
207 let trace_cb = Arc::clone(&trace);
208 let on_event: Arc<crate::agent::events::OnEvent> = Arc::new(move |ev: AgentEvent| {
209 let Ok(mut t) = trace_cb.lock() else { return };
210 match ev {
211 AgentEvent::TurnStarted { turn, .. } => t.turns = t.turns.max(turn),
212 AgentEvent::ToolCallStarted { tool_name, .. } => {
213 t.tool_calls += 1;
214 t.tools.push(tool_name);
215 }
216 AgentEvent::RunCompleted { total_usage, .. } => {
217 t.input_tokens = total_usage.input_tokens;
218 t.output_tokens = total_usage.output_tokens;
219 }
220 _ => {}
221 }
222 });
223
224 let prune = crate::agent::pruner::SessionPruneConfig {
228 keep_recent_n: 3,
229 pruned_tool_result_max_bytes: 256,
230 preserve_task: true,
231 };
232 match BrowserAgentBuilder::new(Arc::clone(provider))
233 .name(task.name.clone())
234 .allow_hosts(task.allow_hosts.clone())
235 .max_turns(task.max_turns)
236 .tools_allow(task.tools.clone())
237 .on_event(on_event)
238 .session_prune(prune)
239 .max_identical_tool_calls(3)
242 .build_with_tools(tools.to_vec())
243 {
244 Err(e) => r.error = Some(format!("build: {e}")),
245 Ok(agent) => match agent.execute(&task.instruction).await {
246 Err(e) => r.error = Some(format!("run: {e}")),
247 Ok(out) => {
248 r.cost_usd = out.estimated_cost_usd;
249 r.answer_excerpt = out.result.chars().take(160).collect();
250 answer = out.result;
251 }
252 },
253 }
254
255 let build_failed = matches!(&r.error, Some(e) if e.starts_with("build:"));
265 if !build_failed {
266 let snap = match snapshot_tool {
267 Some(t) => t
268 .execute(ctx, serde_json::json!({}))
269 .await
270 .map(|o| o.content)
271 .unwrap_or_default(),
272 None => String::new(),
273 };
274 r.passed = task.oracle.grade(&snap, &answer);
275 r.final_snapshot = snap;
276 }
277 r.answer = answer;
278 if let Ok(t) = trace.lock() {
281 r.turns = t.turns;
282 r.tool_calls = t.tool_calls;
283 r.trace = t.tools.clone();
284 r.input_tokens = t.input_tokens;
285 r.output_tokens = t.output_tokens;
286 }
287 r.millis = started.elapsed().as_millis();
288 if let Some((pin, pout)) = provider.model_name().and_then(model_price_per_mtok) {
296 r.cost_usd = Some(estimate_cost_usd(
297 r.input_tokens,
298 r.output_tokens,
299 pin,
300 pout,
301 ));
302 }
303 r
304}
305
306pub fn estimate_cost_usd(
308 input_tokens: u32,
309 output_tokens: u32,
310 in_per_mtok: f64,
311 out_per_mtok: f64,
312) -> f64 {
313 (input_tokens as f64 / 1_000_000.0) * in_per_mtok
314 + (output_tokens as f64 / 1_000_000.0) * out_per_mtok
315}
316
317pub fn model_price_per_mtok(model: &str) -> Option<(f64, f64)> {
323 match model {
324 "deepseek/deepseek-v3.2" => Some((0.2520, 0.3780)),
325 "moonshotai/kimi-k2-0905" => Some((0.6000, 2.5000)),
326 "z-ai/glm-4.6" => Some((0.4286, 1.7143)),
327 "qwen/qwen3-235b-a22b-2507" => Some((0.0710, 0.1000)),
328 "qwen/qwen3-235b-a22b-thinking-2507" => Some((0.0780, 0.3600)),
329 "minimax/minimax-m2" => Some((0.2600, 1.0000)),
330 "google/gemini-2.5-pro" => Some((1.2500, 10.0000)),
331 "google/gemini-3.1-pro-preview" => Some((2.0000, 12.0000)),
332 "x-ai/grok-4.3" => Some((1.2500, 2.5000)),
333 "x-ai/grok-4.20" => Some((1.2500, 2.5000)),
334 "openai/gpt-5.1" => Some((1.2500, 10.0000)),
335 "openai/gpt-4.1" => Some((2.0000, 8.0000)),
336 "anthropic/claude-opus-4.8" => Some((5.0000, 25.0000)),
337 _ => None,
338 }
339}
340
341pub fn scorecard(results: &[BenchResult]) -> String {
343 use std::fmt::Write as _;
344 let passed = results.iter().filter(|r| r.passed).count();
345 let total_cost: f64 = results.iter().filter_map(|r| r.cost_usd).sum();
346 let mut s = String::new();
347 let _ = writeln!(
348 s,
349 "=== Browser-agent benchmark: {}/{} tasks passed (est. ${:.5} total) ===",
350 passed,
351 results.len(),
352 total_cost
353 );
354 let _ = writeln!(
355 s,
356 "{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
357 "task", "diff", "pass", "att", "calls", "in_tok", "out_tok", "cost$", "ms"
358 );
359 for r in results {
360 let cost = match r.cost_usd {
361 Some(c) => format!("{c:.5}"),
362 None => "-".to_string(),
363 };
364 let _ = writeln!(
365 s,
366 "{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
367 r.name,
368 r.difficulty,
369 if r.passed { "PASS" } else { "FAIL" },
370 r.attempts,
371 r.tool_calls,
372 r.input_tokens,
373 r.output_tokens,
374 cost,
375 r.millis
376 );
377 if let Some(e) = &r.error {
378 let _ = writeln!(s, " ! {e}");
379 }
380 if !r.trace.is_empty() {
383 let _ = writeln!(s, " trace: {}", r.trace.join(" -> "));
384 }
385 if !r.answer_excerpt.is_empty() {
386 let _ = writeln!(s, " answer: {}", r.answer_excerpt.replace('\n', " "));
387 }
388 }
389 s
390}
391
392pub fn bench_suite() -> Vec<BenchTask> {
398 vec![
399 BenchTask {
401 name: "example_extract".into(),
402 difficulty: "easy".into(),
403 allow_hosts: vec!["example.com".into()],
404 instruction: "Go to https://example.com and report the main heading text shown on \
405 the page."
406 .into(),
407 oracle: Oracle::FinalPageContains("Example Domain".into()),
408 max_turns: 8,
409 tools: vec!["navigate_page".into(), "take_snapshot".into()],
410 },
411 BenchTask {
414 name: "the_internet_login".into(),
415 difficulty: "medium".into(),
416 allow_hosts: vec!["the-internet.herokuapp.com".into()],
417 instruction: "Go to https://the-internet.herokuapp.com/login and log in with \
418 username \"tomsmith\" and password \"SuperSecretPassword!\". Confirm \
419 you reached the secure area."
420 .into(),
421 oracle: Oracle::FinalPageContains("You logged into a secure area!".into()),
422 max_turns: 22,
423 tools: vec![
424 "navigate_page".into(),
425 "take_snapshot".into(),
426 "fill".into(),
427 "fill_form".into(),
428 "click".into(),
429 ],
430 },
431 BenchTask {
434 name: "dynamic_loading_settle".into(),
435 difficulty: "hard".into(),
436 allow_hosts: vec!["the-internet.herokuapp.com".into()],
437 instruction: "Go to https://the-internet.herokuapp.com/dynamic_loading/2 , click the \
438 Start button, wait for the content to finish loading, and report the \
439 text that appears."
440 .into(),
441 oracle: Oracle::FinalPageContains("Hello World!".into()),
442 max_turns: 18,
443 tools: vec![
444 "navigate_page".into(),
445 "take_snapshot".into(),
446 "click".into(),
447 "wait_for".into(),
448 ],
449 },
450 BenchTask {
453 name: "books_travel_price".into(),
454 difficulty: "hardest".into(),
455 allow_hosts: vec!["books.toscrape.com".into()],
456 instruction: "Go to https://books.toscrape.com , open the Travel category, find the \
457 book titled \"Under the Tuscan Sun\", open its page, and report its \
458 exact price."
459 .into(),
460 oracle: Oracle::AgentAnswerContains("37.33".into()),
461 max_turns: 24,
462 tools: vec![
463 "navigate_page".into(),
464 "take_snapshot".into(),
465 "click".into(),
466 ],
467 },
468 ]
469}
470
471pub fn hard_suite() -> Vec<BenchTask> {
476 let books = || {
477 vec![
478 "navigate_page".to_string(),
479 "take_snapshot".to_string(),
480 "click".to_string(),
481 ]
482 };
483 vec![
484 BenchTask {
486 name: "books_mystery_count".into(),
487 difficulty: "hard".into(),
488 allow_hosts: vec!["books.toscrape.com".into()],
489 instruction: "Go to https://books.toscrape.com , open the Mystery category, and \
490 report exactly how many books are in it (the number of results)."
491 .into(),
492 oracle: Oracle::AgentAnswerContains("32".into()),
493 max_turns: 20,
494 tools: books(),
495 },
496 BenchTask {
498 name: "books_travel_priciest".into(),
499 difficulty: "hardest".into(),
500 allow_hosts: vec!["books.toscrape.com".into()],
501 instruction: "Go to https://books.toscrape.com , open the Travel category, compare \
502 the prices of every book in it, and report the TITLE of the most \
503 expensive one."
504 .into(),
505 oracle: Oracle::AgentAnswerContains("The Great Railway Bazaar".into()),
506 max_turns: 26,
507 tools: books(),
508 },
509 BenchTask {
511 name: "books_catalog_total".into(),
512 difficulty: "hard".into(),
513 allow_hosts: vec!["books.toscrape.com".into()],
514 instruction: "Go to https://books.toscrape.com and report the total number of books \
515 listed in the whole catalogue."
516 .into(),
517 oracle: Oracle::AgentAnswerContains("1000".into()),
518 max_turns: 16,
519 tools: books(),
520 },
521 ]
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::error::Error;
528 use crate::llm::types::{
529 CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
530 };
531
532 const LOGIN_OK_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/secure"
533 uid=1_1 StaticText "You logged into a secure area!"
534 uid=1_2 button "Logout""#;
535
536 const LOGIN_FAIL_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/login"
537 uid=1_1 StaticText "Your username is invalid!"
538 uid=1_2 textbox "Username""#;
539
540 #[test]
541 fn final_page_contains_grades_on_banner() {
542 let o = Oracle::FinalPageContains("You logged into a secure area!".into());
543 assert!(o.grade(LOGIN_OK_SNAP, "I logged in."));
544 assert!(!o.grade(LOGIN_FAIL_SNAP, "I logged in.")); }
546
547 #[test]
548 fn url_contains_grades_on_redirect() {
549 let o = Oracle::UrlContains("/secure".into());
550 assert!(o.grade(LOGIN_OK_SNAP, ""));
551 assert!(!o.grade(LOGIN_FAIL_SNAP, ""));
552 }
553
554 #[test]
555 fn agent_answer_contains_is_case_insensitive() {
556 let o = Oracle::AgentAnswerContains("£51.77".into());
557 assert!(o.grade("", "The price is £51.77 incl. tax."));
558 assert!(!o.grade("", "I could not find the price."));
559 let o2 = Oracle::AgentAnswerContains("Hello World".into());
560 assert!(o2.grade("", "the revealed text was HELLO WORLD!"));
561 }
562
563 #[test]
564 fn oracle_does_not_trust_agent_over_page() {
565 let o = Oracle::FinalPageContains("secure area".into());
568 assert!(
569 !o.grade(LOGIN_FAIL_SNAP, "Done! Successfully logged in."),
570 "page is the source of truth, not the agent's claim"
571 );
572 }
573
574 #[test]
575 fn snapshot_url_extracts_root_url() {
576 assert_eq!(
577 snapshot_url(LOGIN_OK_SNAP),
578 Some("https://the-internet.herokuapp.com/secure")
579 );
580 assert_eq!(snapshot_url("uid=1_0 RootWebArea \"x\""), None); assert_eq!(snapshot_url(""), None);
582 }
583
584 #[test]
585 fn bench_suite_is_wellformed() {
586 let suite = bench_suite();
587 assert_eq!(suite.len(), 4, "tiered suite has 4 tasks");
588 for t in &suite {
589 assert!(!t.name.is_empty());
590 assert!(
591 !t.allow_hosts.is_empty(),
592 "{} must allowlist a host",
593 t.name
594 );
595 assert!(
596 t.instruction.contains("http"),
597 "{} instruction must name a URL",
598 t.name
599 );
600 assert!(t.max_turns >= 4, "{} max_turns too low", t.name);
601 assert!(
603 t.allow_hosts
604 .iter()
605 .any(|h| t.instruction.contains(h.as_str())),
606 "{} instruction must target its allowlisted host",
607 t.name
608 );
609 }
610 let diffs: Vec<_> = suite.iter().map(|t| t.difficulty.as_str()).collect();
611 assert!(
612 diffs.contains(&"easy") && diffs.contains(&"hardest"),
613 "suite must span easy..hardest, got {diffs:?}"
614 );
615 }
616
617 async fn live_setup() -> (String, Vec<Arc<dyn Tool>>, Vec<BenchTask>, usize) {
630 let key = std::env::var("LLM_API_KEY")
631 .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
632 .expect("set LLM_API_KEY or OPENROUTER_API_KEY to run this live benchmark");
633 let chrome = "/usr/bin/google-chrome";
634 let extra: Vec<String> = if std::path::Path::new(chrome).exists() {
635 vec!["--executable-path".to_string(), chrome.to_string()]
636 } else {
637 Vec::new()
638 };
639 let tools = crate::connect_preset_with_args("chrome-devtools", &extra)
640 .await
641 .expect("connect chrome-devtools preset");
642 let mut suite = bench_suite();
643 if let Ok(only) = std::env::var("BENCH_ONLY") {
644 suite.retain(|t| t.name.contains(&only));
645 assert!(!suite.is_empty(), "BENCH_ONLY={only} matched no task");
646 }
647 let attempts = std::env::var("BENCH_ATTEMPTS")
648 .ok()
649 .and_then(|v| v.parse::<usize>().ok())
650 .unwrap_or(3);
651 (key, tools, suite, attempts)
652 }
653
654 #[tokio::test]
655 #[ignore = "live: needs OpenRouter key + spawns real Chrome; hits 3 external sites"]
656 async fn live_kimi_browser_benchmark() {
657 let (key, tools, suite, attempts) = live_setup().await;
658 let model = std::env::var("BENCH_MODEL")
663 .unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
664 let provider = Arc::new(crate::OpenRouterProvider::new(key, model));
665 let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
666 eprintln!("\n{}", scorecard(&results));
667 if let Some(easy) = results.iter().find(|r| r.name == "example_extract") {
668 assert!(
669 easy.passed,
670 "harness sanity: the easy extract task must pass (see scorecard above)"
671 );
672 }
673 }
674
675 #[tokio::test]
688 #[ignore = "live: needs OpenRouter key + spawns real Chrome; runs the suite per model"]
689 async fn live_model_matrix_benchmark() {
690 let (key, tools, suite, attempts) = live_setup().await;
691 let models: Vec<String> = std::env::var("BENCH_MODELS")
701 .unwrap_or_else(|_| {
702 "deepseek/deepseek-v3.2,moonshotai/kimi-k2-0905,z-ai/glm-4.6,\
703 qwen/qwen3-235b-a22b-2507,minimax/minimax-m2,\
704 google/gemini-2.5-pro,google/gemini-3.1-pro-preview,\
705 x-ai/grok-4.3,x-ai/grok-4.20,\
706 openai/gpt-5.1,openai/gpt-4.1"
707 .to_string()
708 })
709 .split(',')
710 .map(|s| s.trim().to_string())
711 .filter(|s| !s.is_empty())
712 .collect();
713
714 let mut summary =
715 String::from("\n=== MODEL MATRIX (same tasks, same harness, pass@k) ===\n");
716 for model in &models {
717 let provider = Arc::new(crate::OpenRouterProvider::new(key.clone(), model.clone()));
718 let results =
719 run_bench_with_retries(Arc::clone(&provider), tools.clone(), &suite, attempts)
720 .await;
721 let passed = results.iter().filter(|r| r.passed).count();
722 let in_tok: u32 = results.iter().map(|r| r.input_tokens).sum();
723 let out_tok: u32 = results.iter().map(|r| r.output_tokens).sum();
724 let per_task: Vec<String> = results
725 .iter()
726 .map(|r| {
727 format!(
728 "{}:{}",
729 r.name.split('_').next().unwrap_or(&r.name),
730 if r.passed { "P" } else { "F" }
731 )
732 })
733 .collect();
734 eprintln!("\n--- {model} ---\n{}", scorecard(&results));
735 summary.push_str(&format!(
736 "{:<34} {}/{} in={:>7} out={:>6} [{}]\n",
737 model,
738 passed,
739 results.len(),
740 in_tok,
741 out_tok,
742 per_task.join(" ")
743 ));
744 }
745 eprintln!("{summary}");
746 }
747
748 async fn opus_judge<P: crate::llm::LlmProvider>(
754 judge: &P,
755 task: &str,
756 snapshot: &str,
757 answer: &str,
758 ) -> (bool, String, u32, u32) {
759 use crate::browser::judge::{
760 CompletionVerdict, build_completion_prompt, parse_completion_verdict,
761 };
762 use crate::llm::types::{CompletionRequest, ContentBlock, Message};
763 let prompt = build_completion_prompt(task, &[], &[], snapshot);
764 let req = CompletionRequest {
766 system: String::new(),
767 messages: vec![Message::user(format!(
768 "{prompt}\n\n# The agent's reported answer\n{answer}"
769 ))],
770 tools: Vec::new(),
771 max_tokens: 1024,
772 tool_choice: None,
773 reasoning_effort: None,
774 };
775 match judge.complete(req).await {
776 Err(e) => (false, format!("judge error: {e}"), 0, 0),
777 Ok(resp) => {
778 let text: String = resp
779 .content
780 .iter()
781 .filter_map(|b| match b {
782 ContentBlock::Text { text } => Some(text.as_str()),
783 _ => None,
784 })
785 .collect();
786 let (pass, reason) = match parse_completion_verdict(&text) {
787 Some(CompletionVerdict::Complete) => (true, "COMPLETE".to_string()),
788 Some(CompletionVerdict::Incomplete(r)) => (false, format!("INCOMPLETE: {r}")),
789 Some(CompletionVerdict::Uncertain(r)) => (false, format!("UNCERTAIN: {r}")),
790 None => (false, "unparseable judge reply".to_string()),
791 };
792 (
793 pass,
794 reason,
795 resp.usage.input_tokens,
796 resp.usage.output_tokens,
797 )
798 }
799 }
800 }
801
802 #[tokio::test]
815 #[ignore = "live: needs OpenRouter key + Chrome; runs hard suite + Opus judge"]
816 async fn live_qwen_hard_opus_judge() {
817 let (key, tools, _suite, attempts) = live_setup().await;
818 let agent_model = std::env::var("BENCH_MODEL")
819 .unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
820 let judge_model = std::env::var("JUDGE_MODEL")
821 .unwrap_or_else(|_| "anthropic/claude-opus-4.8".to_string());
822
823 let provider = Arc::new(crate::OpenRouterProvider::new(
824 key.clone(),
825 agent_model.clone(),
826 ));
827 let judge = crate::OpenRouterProvider::new(key, judge_model.clone());
828
829 let suite = hard_suite();
830 let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
831 eprintln!("\n=== HARD SUITE: {agent_model} (agent) judged by {judge_model} ===");
832 eprintln!("{}", scorecard(&results));
833
834 let (jp_in, jp_out) = model_price_per_mtok(&judge_model).unwrap_or((0.0, 0.0));
835 let mut judge_in = 0u32;
836 let mut judge_out = 0u32;
837 let mut both_pass = 0usize;
838 for r in &results {
839 let task = suite.iter().find(|t| t.name == r.name);
840 let instr = task.map(|t| t.instruction.as_str()).unwrap_or("");
841 let (jpass, reason, jin, jout) =
842 opus_judge(&judge, instr, &r.final_snapshot, &r.answer).await;
843 judge_in += jin;
844 judge_out += jout;
845 if r.passed && jpass {
846 both_pass += 1;
847 }
848 eprintln!(
849 "{:<24} oracle={} opus={} | {}",
850 r.name,
851 if r.passed { "PASS" } else { "FAIL" },
852 if jpass { "PASS" } else { "FAIL" },
853 reason.chars().take(120).collect::<String>()
854 );
855 }
856 let judge_cost = estimate_cost_usd(judge_in, judge_out, jp_in, jp_out);
857 eprintln!(
858 "\noracle+opus agree-PASS: {}/{}. Opus judge tokens in={} out={} (~${:.5}).",
859 both_pass,
860 results.len(),
861 judge_in,
862 judge_out,
863 judge_cost
864 );
865 }
866
867 #[test]
868 fn hard_suite_is_wellformed() {
869 let s = hard_suite();
870 assert!(s.len() >= 3, "hard suite has >=3 tasks");
871 for t in &s {
872 assert!(t.instruction.contains("http"), "{} has a URL", t.name);
873 assert!(!t.tools.is_empty(), "{} pins tools", t.name);
874 assert!(t.max_turns >= 8, "{} has headroom", t.name);
875 }
876 let joined: String = s
878 .iter()
879 .map(|t| match &t.oracle {
880 Oracle::AgentAnswerContains(v) => v.clone(),
881 _ => String::new(),
882 })
883 .collect::<Vec<_>>()
884 .join("|");
885 assert!(
886 joined.contains("32")
887 && joined.contains("Great Railway Bazaar")
888 && joined.contains("1000")
889 );
890 }
891
892 #[test]
893 fn scorecard_counts_and_formats() {
894 let results = vec![
895 BenchResult {
896 name: "login".into(),
897 difficulty: "hard".into(),
898 passed: true,
899 tool_calls: 5,
900 input_tokens: 1200,
901 output_tokens: 80,
902 cost_usd: Some(0.01),
903 millis: 4200,
904 turns: 4,
905 attempts: 1,
906 trace: vec![
907 "navigate_page".into(),
908 "take_snapshot".into(),
909 "fill_form".into(),
910 "click".into(),
911 ],
912 answer_excerpt: "logged in".into(),
913 error: None,
914 ..Default::default()
915 },
916 BenchResult {
917 name: "basket".into(),
918 difficulty: "hardest".into(),
919 passed: false,
920 tool_calls: 9,
921 input_tokens: 3000,
922 output_tokens: 140,
923 cost_usd: None,
924 millis: 9000,
925 turns: 14,
926 attempts: 3,
927 trace: vec!["navigate_page".into(), "take_snapshot".into()],
928 answer_excerpt: String::new(),
929 error: Some("run: timeout".into()),
930 ..Default::default()
931 },
932 ];
933 let card = scorecard(&results);
934 assert!(card.contains("1/2 tasks passed"));
935 assert!(card.contains("login"));
936 assert!(card.contains("basket"));
937 assert!(card.contains("PASS"));
938 assert!(card.contains("FAIL"));
939 assert!(card.contains("! run: timeout"));
940 assert!(card.contains("trace: navigate_page -> take_snapshot"));
942 assert!(card.contains("cost$"), "scorecard has a cost column");
945 assert!(card.contains("0.01000"), "priced row shows its cost");
946 assert!(
947 card.contains("est. $0.01000 total"),
948 "header shows total cost"
949 );
950 }
951
952 #[test]
953 fn estimate_cost_is_tokens_times_price() {
954 let c = estimate_cost_usd(1_000_000, 1_000_000, 0.07, 0.10);
956 assert!((c - 0.17).abs() < 1e-9, "got {c}");
957 let q = estimate_cost_usd(73477, 429, 0.071, 0.10);
959 assert!((0.005..0.006).contains(&q), "qwen run ~ $0.0053, got {q}");
960 assert_eq!(estimate_cost_usd(0, 0, 1.0, 1.0), 0.0);
961 }
962
963 #[test]
964 fn price_table_known_and_unknown() {
965 assert_eq!(
966 model_price_per_mtok("qwen/qwen3-235b-a22b-2507"),
967 Some((0.0710, 0.1000))
968 );
969 assert_eq!(
970 model_price_per_mtok("anthropic/claude-opus-4.8"),
971 Some((5.0, 25.0))
972 );
973 assert_eq!(model_price_per_mtok("nonexistent/model"), None);
974 }
975
976 struct PricedMock;
980 impl LlmProvider for PricedMock {
981 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
982 Ok(CompletionResponse {
983 content: vec![ContentBlock::Text {
984 text: "the answer is 42".to_string(),
985 }],
986 stop_reason: StopReason::EndTurn,
987 reasoning: None,
988 usage: TokenUsage {
989 input_tokens: 1000,
990 output_tokens: 100,
991 ..Default::default()
992 },
993 model: None,
994 })
995 }
996 fn model_name(&self) -> Option<&str> {
997 Some("qwen/qwen3-235b-a22b-2507")
998 }
999 }
1000
1001 #[tokio::test]
1006 async fn run_task_once_derives_cost_from_price_table() {
1007 let provider = Arc::new(PricedMock);
1008 let tools: Vec<Arc<dyn Tool>> = Vec::new();
1009 let ctx = ExecutionContext::default();
1010 let task = BenchTask {
1011 name: "cost".to_string(),
1012 difficulty: "easy".to_string(),
1013 allow_hosts: vec![],
1014 instruction: "say the answer".to_string(),
1015 oracle: Oracle::AgentAnswerContains("42".to_string()),
1016 max_turns: 1,
1017 tools: vec![],
1018 };
1019 let r = run_task_once(&provider, &tools, None, &ctx, &task).await;
1020 assert!(
1022 r.input_tokens > 0,
1023 "token counts must be captured from the run (got {})",
1024 r.input_tokens
1025 );
1026 let (pin, pout) = model_price_per_mtok("qwen/qwen3-235b-a22b-2507").unwrap();
1029 let expected = estimate_cost_usd(r.input_tokens, r.output_tokens, pin, pout);
1030 assert_eq!(
1031 r.cost_usd,
1032 Some(expected),
1033 "cost must derive from the price table using the real token counts"
1034 );
1035 assert_ne!(r.cost_usd, Some(0.0), "cost must be nonzero for a real run");
1036 }
1037}