metrale_bench/benchmarks/agentic/
mod.rs1pub mod agent;
17mod params;
18pub mod preflight;
19mod render;
20pub mod score;
21mod verdict;
22pub mod warm;
23
24use std::future::Future;
25use std::path::PathBuf;
26use std::time::{Duration, Instant};
27
28use anyhow::{Context, Result, bail};
29
30use crate::benchmark::{Benchmark, BenchmarkDescriptor};
31use crate::benchmarks::one_line;
32use crate::http;
33use crate::metadata::PluginMetadata;
34use crate::params::{ParamSpec, ParamValues};
35use crate::plugin::{Plugin, PluginHandle};
36use crate::result::{BenchmarkResult, LogLine, RunStatus, Verdict};
37
38pub const PROMPT: &str = "Please create a pure rust Axum project here in the current working \
41directory. Just have a ping/pong endpoint. The server MUST bind to the port from the \
42METRALE_HARNESS_PORT env var (default 3001) — use `let port: u16 = \
43std::env::var(\"METRALE_HARNESS_PORT\").unwrap_or_else(|_| \"3001\".to_string()).parse().unwrap();` \
44then bind to `0.0.0.0:port`. Add tests, run them and prove all tests pass, then run the server and \
45use curl to prove it works. Whenever you run the server or any long-lived process in the \
46background, always start it detached with its output redirected to a file (for example `setsid \
47cargo run > /tmp/server.log 2>&1 &`) so your shell never blocks waiting on it, and wrap any \
48command that might hang, such as curl checks or process kills, in a short `timeout 15`. Finally, \
49tear down the server by killing whatever is listening on its port rather than guessing the process \
50name, always wrapped in a short timeout so it can never stall your shell, for example `timeout 5 \
51fuser -k ${METRALE_HARNESS_PORT:-3001}/tcp 2>/dev/null || true`.";
52
53mod descriptors;
54pub use descriptors::{DESCRIPTOR, METADATA};
55
56#[derive(Default)]
57struct IterationRow {
58 index: usize,
59 wall_s: f64,
62 agent_wall_s: f64,
66 webserver_ok: bool,
67 directions: score::Directions,
68 turns: usize,
69 tool_calls: usize,
70 completion_tokens: usize,
71 hit_turn_cap: bool,
74 truncated_turns: usize,
76 unparsed_call_turns: usize,
78 note: String,
79}
80
81#[derive(Default)]
82pub struct AgenticWebserver {
83 handle: Option<PluginHandle>,
84 iterations: usize,
85 max_turns: usize,
86 command_timeout: Duration,
87 request_timeout: Duration,
88 build_timeout: Duration,
89 serve_timeout: Duration,
90 max_tokens: usize,
91 wall_budget_s: f64,
92 s_per_turn_budget: f64,
93 cursor: usize,
94 rows: Vec<IterationRow>,
95 sandbox_root: Option<PathBuf>,
96 cargo_target_dir: Option<PathBuf>,
97 started: Option<Instant>,
98 probed: bool,
99}
100
101impl AgenticWebserver {
102 fn handle(&self) -> Result<&PluginHandle> {
103 self.handle.as_ref().context("benchmark was not loaded")
104 }
105
106 fn elapsed(&self) -> Duration {
107 self.started.map(|s| s.elapsed()).unwrap_or_default()
108 }
109
110 fn total_wall(&self) -> f64 {
111 self.rows.iter().map(|r| r.wall_s).sum()
112 }
113
114 fn total_agent_wall(&self) -> f64 {
116 self.rows.iter().map(|r| r.agent_wall_s).sum()
117 }
118
119 fn total_turns(&self) -> usize {
120 verdict::total_turns(&self.rows)
121 }
122
123 fn total_tool_calls(&self) -> usize {
124 self.rows.iter().map(|r| r.tool_calls).sum()
125 }
126
127 fn total_completion_tokens(&self) -> usize {
128 self.rows.iter().map(|r| r.completion_tokens).sum()
129 }
130
131 fn seconds_per_turn(&self) -> Option<f64> {
133 verdict::seconds_per_turn(self.total_agent_wall(), self.total_turns())
134 }
135
136 async fn run_iteration(&mut self, index: usize) -> Result<IterationRow> {
137 let handle = self.handle()?.clone();
138 let root = self
139 .sandbox_root
140 .clone()
141 .context("sandbox root was not prepared")?;
142 let sandbox = root.join(format!("run-{index:02}"));
143 let _ = std::fs::remove_dir_all(&sandbox);
146 std::fs::create_dir_all(&sandbox)
147 .with_context(|| format!("creating sandbox {}", sandbox.display()))?;
148
149 let cfg = agent::AgentConfig {
150 sandbox: sandbox.clone(),
151 max_turns: self.max_turns,
152 command_timeout: self.command_timeout,
153 request_timeout: self.request_timeout,
154 max_tokens: self.max_tokens,
155 cargo_target_dir: self.cargo_target_dir.clone(),
156 };
157
158 let started = Instant::now();
159 let transcript = agent::run_task(&handle, &cfg, PROMPT).await?;
160 let agent_wall_s = started.elapsed().as_secs_f64();
162 handle.status(format!("run {index}: scoring"));
163 let web = score::webserver_test(
164 &sandbox,
165 self.cargo_target_dir.as_deref(),
166 self.build_timeout,
167 self.serve_timeout,
168 )
169 .await;
170 let wall_s = started.elapsed().as_secs_f64();
172 let directions = score::followed_directions(&transcript.commands, &sandbox);
173
174 let mut note = web.error.clone();
175 if transcript.hit_turn_cap {
176 note = format!("turn cap ({}) reached; {note}", self.max_turns);
177 }
178 let missing = directions.missing();
181 if !missing.is_empty() {
182 note = format!("missing: {}; {note}", missing.join(", "));
183 }
184 Ok(IterationRow {
185 index,
186 wall_s,
187 agent_wall_s,
188 webserver_ok: web.webserver_ok,
189 directions,
190 turns: transcript.turns,
191 tool_calls: transcript.tool_calls,
192 completion_tokens: transcript.completion_tokens,
193 hit_turn_cap: transcript.hit_turn_cap,
194 truncated_turns: transcript.truncated_turns,
195 unparsed_call_turns: transcript.unparsed_call_turns,
196 note: one_line(note),
197 })
198 }
199
200 fn metrics(&self) -> std::collections::BTreeMap<String, f64> {
203 let n = self.rows.len();
204 let mut m = std::collections::BTreeMap::new();
205 m.insert("iterations".to_string(), n as f64);
206 m.insert(
207 "webserver_ok".to_string(),
208 self.rows.iter().filter(|r| r.webserver_ok).count() as f64,
209 );
210 m.insert(
211 "followed_directions".to_string(),
212 self.rows.iter().filter(|r| r.directions.overall()).count() as f64,
213 );
214 m.insert("sum_wall_s".to_string(), self.total_wall());
215 m.insert("sum_agent_wall_s".to_string(), self.total_agent_wall());
216 m.insert("sum_turns".to_string(), self.total_turns() as f64);
217 m.insert("sum_tool_calls".to_string(), self.total_tool_calls() as f64);
218 m.insert(
219 "sum_completion_tokens".to_string(),
220 self.total_completion_tokens() as f64,
221 );
222 if let Some(spt) = self.seconds_per_turn() {
225 m.insert("s_per_turn".to_string(), spt);
226 }
227 if self.total_agent_wall() > 0.0 {
230 m.insert(
231 "decode_tps".to_string(),
232 self.total_completion_tokens() as f64 / self.total_agent_wall(),
233 );
234 }
235
236 m.extend(score::per_step_tallies(
237 &self.rows.iter().map(|r| &r.directions).collect::<Vec<_>>(),
238 ));
239 m.extend(trajectory_diagnostics(&self.rows));
240 m
241 }
242
243 fn verdict(&self) -> Verdict {
244 verdict::verdict(
245 &self.rows,
246 self.total_wall(),
247 self.total_agent_wall(),
248 self.wall_budget_s,
249 self.s_per_turn_budget,
250 )
251 }
252}
253
254fn trajectory_diagnostics(rows: &[IterationRow]) -> std::collections::BTreeMap<String, f64> {
259 let mut m = std::collections::BTreeMap::new();
260 m.insert(
261 "sum_hit_turn_cap".to_string(),
262 rows.iter().filter(|r| r.hit_turn_cap).count() as f64,
263 );
264 m.insert(
265 "sum_truncated_turns".to_string(),
266 rows.iter().map(|r| r.truncated_turns).sum::<usize>() as f64,
267 );
268 m.insert(
269 "sum_unparsed_call_turns".to_string(),
270 rows.iter().map(|r| r.unparsed_call_turns).sum::<usize>() as f64,
271 );
272 if let Some(max) = rows.iter().map(|r| r.turns).max() {
273 m.insert("max_iter_turns".to_string(), max as f64);
274 }
275 m
276}
277
278impl Plugin for AgenticWebserver {
279 fn metadata(&self) -> &'static PluginMetadata {
280 &METADATA
281 }
282
283 fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
284 self.started = Some(Instant::now());
285 let store = handle.artifacts().clone();
286 self.handle = Some(handle.clone());
287 async move {
288 crate::python::run(std::path::Path::new("cargo"), &["--version"], None)
291 .await
292 .context(
293 "cargo is not on PATH — this benchmark builds the code the model writes",
294 )?;
295 let root = store.runs_dir(DESCRIPTOR.id)?.join("sandbox");
296 std::fs::create_dir_all(&root)?;
297 self.sandbox_root = Some(root);
298 self.cargo_target_dir = Some(warm::prepare(&handle).await?);
301 Ok(())
302 }
303 }
304}
305
306impl Benchmark for AgenticWebserver {
307 fn descriptor(&self) -> &'static BenchmarkDescriptor {
308 &DESCRIPTOR
309 }
310
311 fn parameters(&self) -> Vec<ParamSpec> {
312 params::parameters()
313 }
314
315 fn configure(&mut self, values: &ParamValues) -> Result<()> {
316 let specs = self.parameters();
317 values.validate_against(&specs)?;
318 self.iterations = values.usize("iterations")?;
319 self.wall_budget_s = values.float("wall_budget_s")?;
320 self.s_per_turn_budget = values.float("s_per_turn_budget")?;
321 self.max_turns = values.usize("max_turns")?;
322 self.command_timeout = Duration::from_secs(values.usize("command_timeout_s")? as u64);
323 self.build_timeout = Duration::from_secs(values.usize("build_timeout_s")? as u64);
324 self.serve_timeout = Duration::from_secs(values.usize("serve_timeout_s")? as u64);
325 self.max_tokens = values.usize("max_tokens")?;
326 self.request_timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
327 self.cursor = 0;
328 self.rows.clear();
329 Ok(())
330 }
331
332 async fn next(&mut self) -> Result<BenchmarkResult> {
333 let handle = self.handle()?.clone();
334 handle.check_cancelled()?;
335 let total = self.iterations as u64;
336
337 if !self.probed {
338 self.probed = true;
339 http::probe(handle.target(), Duration::from_secs(10))
340 .await
341 .context("endpoint probe failed — check the target URL and port")?;
342 preflight::sanity_check(&handle, Duration::from_secs(60)).await?;
345 let root = self.sandbox_root.clone().context("no sandbox root")?;
346 return Ok(BenchmarkResult::running("probe", self.elapsed())
347 .with_progress(0, total)
348 .log_line(LogLine::info(format!(
349 "{total} iteration(s) · sandbox {}",
350 root.display()
351 )))
352 .log_line(LogLine::warn(
353 "this benchmark executes model-authored shell inside the sandbox",
354 )));
355 }
356
357 if self.cursor >= self.iterations {
358 if self.rows.is_empty() {
359 bail!("no iterations ran");
360 }
361 return Ok(BenchmarkResult {
362 status: RunStatus::Completed,
363 ..BenchmarkResult::running("done", self.elapsed())
364 }
365 .with_progress(total, total)
366 .with_summary(self.summary())
367 .with_table(self.table())
368 .with_metrics(self.metrics())
369 .with_verdict(self.verdict()));
370 }
371
372 let index = self.cursor;
373 handle.status(format!("run {index}/{}", self.iterations));
374 let row = self.run_iteration(index).await?;
375 let line = LogLine::info(format!(
376 "run {index}: {} · {}/6 steps · {:.1}s · {} turns{}",
377 if row.webserver_ok {
378 "webserver_ok"
379 } else {
380 "FAILED"
381 },
382 row.directions.met(),
383 row.wall_s,
384 row.turns,
385 if row.note.is_empty() {
386 String::new()
387 } else {
388 format!(" · {}", row.note)
389 }
390 ));
391 self.rows.push(row);
392 self.cursor += 1;
393 handle.progress(self.cursor as u64, total);
394 Ok(
395 BenchmarkResult::running(format!("run {index}"), self.elapsed())
396 .with_progress(self.cursor as u64, total)
397 .with_summary(self.summary())
398 .with_table(self.table())
399 .log_line(line),
400 )
401 }
402
403 async fn cleanup(&mut self) -> Result<()> {
406 Ok(())
407 }
408}
409
410#[cfg(test)]
411#[path = "agentic_diagnostics_tests.rs"]
412mod diagnostics_tests;
413#[cfg(test)]
414#[path = "agentic_tests.rs"]
415mod tests;