metrale_bench/benchmarks/agentic/
score.rs1use std::path::Path;
17use std::process::Stdio;
18use std::time::Duration;
19
20use anyhow::Result;
21
22pub const REQUIRED_STEPS: [&str; 6] = [
25 "wrote_project",
26 "wrote_tests",
27 "ran_tests",
28 "ran_server",
29 "curled",
30 "tore_down",
31];
32
33#[derive(Clone, Debug, Default)]
34pub struct WebserverResult {
35 pub webserver_ok: bool,
36 pub build_ok: bool,
37 pub error: String,
38 pub port_used: u16,
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct Directions {
43 pub steps: Vec<(&'static str, bool)>,
44}
45
46impl Directions {
47 pub fn overall(&self) -> bool {
49 !self.steps.is_empty() && self.steps.iter().all(|(_, ok)| *ok)
50 }
51 pub fn missing(&self) -> Vec<&'static str> {
53 self.steps
54 .iter()
55 .filter(|(_, ok)| !*ok)
56 .map(|(name, _)| *name)
57 .collect()
58 }
59 pub fn met(&self) -> usize {
60 self.steps.iter().filter(|(_, ok)| *ok).count()
61 }
62}
63
64pub fn free_port() -> Result<u16> {
69 let listener = reserve_port()?;
70 Ok(listener.local_addr()?.port())
71}
72
73fn reserve_port() -> Result<std::net::TcpListener> {
78 Ok(std::net::TcpListener::bind("127.0.0.1:0")?)
79}
80
81pub async fn webserver_test(
85 sandbox: &Path,
86 cargo_target_dir: Option<&Path>,
87 build_timeout: Duration,
88 serve_timeout: Duration,
89) -> WebserverResult {
90 let mut out = WebserverResult::default();
91 if !sandbox.join("Cargo.toml").is_file() {
94 out.error = "no Cargo.toml was written".into();
95 return out;
96 }
97 if !sandbox.join("src").is_dir() {
98 out.error = "no src/ was written — skipping webserver test".into();
99 return out;
100 }
101 let port_reservation = match reserve_port() {
102 Ok(reservation) => reservation,
103 Err(e) => {
104 out.error = format!("could not reserve a port: {e}");
105 return out;
106 }
107 };
108 let port = match port_reservation.local_addr() {
109 Ok(address) => address.port(),
110 Err(e) => {
111 out.error = format!("could not inspect the reserved port: {e}");
112 return out;
113 }
114 };
115 out.port_used = port;
116
117 let mut build = tokio::process::Command::new("cargo");
118 build
119 .args(["build", "--release"])
120 .current_dir(sandbox)
121 .env("METRALE_HARNESS_PORT", port.to_string())
124 .env_remove("METRALE_AGENT_SHELL")
128 .stdin(Stdio::null())
129 .stdout(Stdio::null())
130 .stderr(Stdio::piped())
131 .kill_on_drop(true);
132 if let Some(dir) = cargo_target_dir {
133 build.env("CARGO_TARGET_DIR", dir);
134 }
135 match tokio::time::timeout(build_timeout, build.output()).await {
136 Ok(Ok(o)) if o.status.success() => out.build_ok = true,
137 Ok(Ok(o)) => {
138 let err = String::from_utf8_lossy(&o.stderr);
139 out.error =
140 super::super::one_line(err.lines().rev().take(6).collect::<Vec<_>>().join(" "));
141 return out;
142 }
143 Ok(Err(e)) => {
144 out.error = format!("cargo build could not start: {e}");
145 return out;
146 }
147 Err(_) => {
148 out.error = format!("cargo build exceeded {}s", build_timeout.as_secs());
149 return out;
150 }
151 }
152
153 let err_log = std::env::temp_dir().join(format!(
156 "metrale-ws-stderr-{}-{port}.log",
157 std::process::id()
158 ));
159 let sink = match std::fs::File::options()
164 .write(true)
165 .create_new(true)
166 .open(&err_log)
167 {
168 Ok(f) => Stdio::from(f),
169 Err(_) => Stdio::null(),
170 };
171
172 let mut serve = tokio::process::Command::new("cargo");
173 serve
174 .args(["run", "--release"])
175 .current_dir(sandbox)
176 .env("METRALE_HARNESS_PORT", port.to_string())
179 .env("RUST_LOG", "warn")
181 .env_remove("METRALE_AGENT_SHELL")
182 .stdin(Stdio::null())
183 .stdout(Stdio::null())
184 .stderr(sink)
185 .kill_on_drop(true);
186 if let Some(dir) = cargo_target_dir {
187 serve.env("CARGO_TARGET_DIR", dir);
188 }
189 drop(port_reservation);
192 let mut child = match serve.spawn() {
193 Ok(c) => c,
194 Err(e) => {
195 out.error = format!("cargo run could not start: {e}");
196 let _ = std::fs::remove_file(&err_log);
197 return out;
198 }
199 };
200 let deadline = tokio::time::Instant::now() + serve_timeout;
203 let mut exited = None;
204 while tokio::time::Instant::now() < deadline {
205 if let Some(body) = ping(port).await
206 && body.to_lowercase().contains("pong")
207 {
208 out.webserver_ok = true;
209 let _ = child.kill().await;
210 let _ = std::fs::remove_file(&err_log);
211 return out;
212 }
213 if let Ok(Some(status)) = child.try_wait() {
216 exited = Some(status);
217 break;
218 }
219 tokio::time::sleep(Duration::from_millis(250)).await;
220 }
221 let _ = child.kill().await;
222 let detail = server_stderr(&err_log);
223 let _ = std::fs::remove_file(&err_log);
224 out.error = match exited {
225 Some(status) => format!("server exited ({status}) before answering /ping{detail}"),
226 None => format!(
227 "/ping did not answer 'pong' within {}s{detail}",
228 serve_timeout.as_secs()
229 ),
230 };
231 out
232}
233
234fn server_stderr(path: &Path) -> String {
238 let text = std::fs::read_to_string(path).unwrap_or_default();
239 if text.trim().is_empty() {
240 return String::new();
241 }
242 let mut note = String::new();
243 if text.contains("Address already in use") || text.contains("EADDRINUSE") {
244 note.push_str(" | server bind failed (port in use)");
245 }
246 let mut tail: Vec<char> = text.chars().rev().take(800).collect();
247 tail.reverse();
248 let tail: String = tail.into_iter().collect();
249 format!("{note} | stderr: {}", super::super::one_line(tail))
250}
251
252async fn ping(port: u16) -> Option<String> {
255 use tokio::io::{AsyncReadExt, AsyncWriteExt};
256 let mut sock = tokio::time::timeout(
257 Duration::from_millis(500),
258 tokio::net::TcpStream::connect(("127.0.0.1", port)),
259 )
260 .await
261 .ok()?
262 .ok()?;
263 let req = format!("GET /ping HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
264 sock.write_all(req.as_bytes()).await.ok()?;
265 let mut buf = Vec::new();
269 let read = async {
270 let mut chunk = [0u8; 4096];
271 while let Ok(n) = sock.read(&mut chunk).await {
272 if n == 0 || buf.len() > 64 * 1024 {
273 break;
274 }
275 buf.extend_from_slice(&chunk[..n]);
276 }
277 };
278 let _ = tokio::time::timeout(Duration::from_secs(2), read).await;
279 Some(String::from_utf8_lossy(&buf).into_owned())
280}
281
282pub fn followed_directions(commands: &[String], sandbox: &Path) -> Directions {
286 let joined = commands.join("\n").to_lowercase();
287 let wrote_project = regular(&sandbox.join("Cargo.toml")) && has_main(sandbox);
291 let wrote_tests = has_tests(sandbox);
292 let ran_tests = contains_cargo(&joined, &["test", "nextest"]);
293 let ran_server = contains_cargo(&joined, &["run"]) || ran_binary(&joined);
294 let curled = ["curl", "wget", "httpie", "httpx"]
295 .iter()
296 .any(|k| word(&joined, k))
297 || word_then(&joined, "nc", "-z");
298 let tore_down =
299 word(&joined, "kill") || word(&joined, "pkill") || word_then(&joined, "fuser", "-k");
300 Directions {
301 steps: REQUIRED_STEPS
302 .iter()
303 .zip([
304 wrote_project,
305 wrote_tests,
306 ran_tests,
307 ran_server,
308 curled,
309 tore_down,
310 ])
311 .map(|(name, ok)| (*name, ok))
312 .collect(),
313 }
314}
315
316fn is_word(c: char) -> bool {
317 c.is_alphanumeric() || c == '_'
318}
319
320fn after_word_start<'a>(hay: &'a str, needle: &'a str) -> impl Iterator<Item = usize> + 'a {
323 hay.match_indices(needle)
324 .filter(|(i, _)| !hay[..*i].chars().next_back().is_some_and(is_word))
325 .map(|(i, m)| i + m.len())
326}
327
328fn word(hay: &str, needle: &str) -> bool {
330 after_word_start(hay, needle).any(|end| !hay[end..].starts_with(is_word))
331}
332
333fn word_then(hay: &str, first: &str, second: &str) -> bool {
336 after_word_start(hay, first).any(|end| {
337 let rest = &hay[end..];
338 let head = rest.trim_start();
339 head.len() < rest.len()
340 && head
341 .strip_prefix(second)
342 .is_some_and(|after| !after.starts_with(is_word))
343 })
344}
345
346fn ran_binary(hay: &str) -> bool {
349 ["target/debug/", "target/release/"].iter().any(|p| {
350 after_word_start(hay, p).any(|end| hay[end..].starts_with(|c: char| !c.is_whitespace()))
351 })
352}
353
354fn contains_cargo(haystack: &str, subs: &[&str]) -> bool {
357 after_word_start(haystack, "cargo").any(|end| {
358 let rest = &haystack[end..];
359 let head = rest.trim_start();
360 head.len() < rest.len()
361 && subs.iter().any(|s| {
362 head.strip_prefix(s)
363 .is_some_and(|after| !after.starts_with(is_word))
364 })
365 })
366}
367
368fn has_main(sandbox: &Path) -> bool {
371 regular(&sandbox.join("src/main.rs"))
372 || walk(sandbox).any(|p| p.file_name().is_some_and(|n| n == "main.rs"))
373}
374
375fn has_tests(sandbox: &Path) -> bool {
379 let tests = sandbox.join("tests");
380 let real_dir = std::fs::symlink_metadata(&tests).is_ok_and(|m| m.is_dir());
381 if real_dir && walk(&tests).any(|p| p.extension().is_some_and(|e| e == "rs")) {
382 return true;
383 }
384 walk(sandbox)
385 .filter(|p| p.extension().is_some_and(|e| e == "rs"))
386 .filter_map(|p| std::fs::read_to_string(p).ok())
387 .any(|s| {
388 s.contains("#[test]") || s.contains("#[cfg(test)]") || s.contains("#[tokio::test]")
389 })
390}
391
392fn walk(root: &Path) -> impl Iterator<Item = std::path::PathBuf> {
401 let mut stack = vec![root.to_path_buf()];
402 let mut files = Vec::new();
403 while let Some(dir) = stack.pop() {
404 let Ok(entries) = std::fs::read_dir(&dir) else {
405 continue;
406 };
407 for entry in entries.flatten() {
408 let name = entry.file_name();
409 if matches!(name.to_str(), Some("target") | Some(".git")) {
410 continue;
411 }
412 let Ok(kind) = entry.file_type() else {
413 continue;
414 };
415 match (kind.is_dir(), kind.is_file()) {
416 (true, _) => stack.push(entry.path()),
417 (_, true) => files.push(entry.path()),
418 _ => {}
419 }
420 }
421 }
422 files.into_iter()
423}
424
425fn regular(path: &Path) -> bool {
427 std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file())
428}
429
430#[cfg(test)]
431#[path = "score_tests.rs"]
432mod tests;
433
434pub fn per_step_tallies(all: &[&Directions]) -> Vec<(String, f64)> {
439 let Some(first) = all.first() else {
440 return Vec::new();
441 };
442 first
443 .steps
444 .iter()
445 .map(|(name, _)| {
446 let met = all
447 .iter()
448 .filter(|d| d.steps.iter().any(|(n, ok)| n == name && *ok))
449 .count();
450 (format!("step:{name}"), met as f64)
451 })
452 .collect()
453}