metrale_bench/benchmarks/agentic/
agent.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The agentic benchmark's agent loop: tool-calling turns against the
4//! served endpoint, with the tools run inside a per-iteration sandbox directory.
5//! Its tools, prompt and caps are modelled on the opencode client that
6//! `bench/fp8_dgx2_drift/harness/run_tier.sh` drives.
7//!
8//! Owner: bench, agentic.
9//! Invariants:
10//! - The loop runs at most `max_turns` turns.
11//! - Every tool result sent back to the model has passed through [`truncate`].
12//! - `run_task` reaps processes whose working directory is in the sandbox after
13//!   the loop returns, whether it returned `Ok` or `Err`.
14
15use std::path::{Path, PathBuf};
16use std::process::Stdio;
17use std::time::Duration;
18
19use anyhow::Result;
20use serde_json::{Value, json};
21
22#[path = "agent_history.rs"]
23mod history;
24#[path = "norm.rs"]
25pub mod norm;
26#[path = "agent_path.rs"]
27mod path_guard;
28#[path = "agent_shell.rs"]
29pub mod shell;
30#[path = "agent_tools.rs"]
31pub mod tools;
32#[path = "trace.rs"]
33pub mod trace;
34use history::{assistant_message, call_id, compact, preserve_thinking};
35pub use path_guard::resolve;
36pub(crate) use shell::run_shell;
37pub use shell::truncate;
38pub use tools::{glob_match, tool_schema};
39
40/// 2026-09-26: Bytes of one tool result; [`truncate`] keeps the head and tail of a
41/// longer one.
42pub(super) const MAX_TOOL_OUTPUT: usize = 8192;
43
44/// 2026-09-26: Characters of conversation (content plus reasoning) above which
45/// [`compact`] elides old reasoning, then old tool results.
46const HISTORY_BUDGET: usize = 96_000;
47
48/// 2026-09-26: The most recent tool results, which [`compact`] never elides.
49const LIVE_TOOL_RESULTS: usize = 4;
50
51/// 2026-09-26: The most recent assistant turns whose reasoning [`compact`] never
52/// elides; older reasoning is elided before any tool result.
53const LIVE_REASONING: usize = 4;
54
55/// 2026-09-26: The request temperature: greedy, so that two runs of the same
56/// build can repeat. `request_body` sends it unless `METRALE_AGENTIC_SAMPLING`
57/// is `model-card`.
58const TEMPERATURE: f64 = 0.0;
59
60/// 2026-09-26: The request seed, sent with [`TEMPERATURE`].
61const SEED: u64 = 0;
62
63/// 2026-09-26: How long `run_shell` waits for its output pumps after the command
64/// exits; it then aborts them, because a pipe inherited by a detached child
65/// never reaches EOF.
66pub(super) const DRAIN_GRACE: Duration = Duration::from_secs(2);
67
68/// 2026-09-26: The agent prompt. The six tools it lists are the ones
69/// [`tool_schema`] defines.
70const AGENT_PROMPT: &str = "\
71You are a coding assistant running locally on Metrale Engine. No data leaves this machine.
72
73You have access to tools for interacting with the filesystem and running commands:
74- **bash**: Execute shell commands (ls, cat, grep, find, git, etc.)
75- **read**: Read file contents
76- **write**: Create or overwrite files
77- **edit**: Edit existing files (find and replace)
78- **glob**: Find files matching a pattern
79- **grep**: Search file contents with regex
80
81When asked to list files, check directories, or run commands, use the **bash** tool.
82When asked to read a file, use the **read** tool.
83
84IMPORTANT: Think briefly, then act. Do NOT describe tool calls in your thinking — just make \
85them directly. Keep thinking short (under 50 words). Never put tool calls inside thinking tags. \
86Use the write tool (not edit) when creating new files.";
87
88/// 2026-09-26: What one agent run did, for scoring and diagnostics.
89#[derive(Default)]
90pub struct Transcript {
91    /// 2026-09-26: Every shell command the agent issued, in order; the input of
92    /// `score::followed_directions`.
93    pub commands: Vec<String>,
94    pub turns: usize,
95    pub tool_calls: usize,
96    /// 2026-09-26: True when the loop ended at the turn cap rather than because
97    /// the agent stopped calling tools.
98    pub hit_turn_cap: bool,
99    /// 2026-09-26: Turns that stopped at `max_tokens` with no tool call and were
100    /// resumed instead of ending the run.
101    pub truncated_turns: usize,
102    /// 2026-09-26: Turns with tool-call syntax in the content and no parsed tool
103    /// call, which were re-asked instead of ending the run.
104    pub unparsed_call_turns: usize,
105    /// 2026-09-26: Sum over turns of each stream's `ChatOutcome::completion_tokens`.
106    pub completion_tokens: usize,
107    pub final_text: String,
108}
109
110pub struct AgentConfig {
111    pub sandbox: PathBuf,
112    pub max_turns: usize,
113    pub command_timeout: Duration,
114    pub request_timeout: Duration,
115    pub max_tokens: usize,
116    /// 2026-09-26: Exported as `CARGO_TARGET_DIR` to every shell command, so the
117    /// agent's builds reuse the dependencies `warm::prepare` compiled.
118    pub cargo_target_dir: Option<PathBuf>,
119}
120
121/// 2026-09-26: The system message: the agent prompt, the model name, and an
122/// environment block naming the sandbox as the working directory. It carries
123/// no date or other per-run value except the sandbox path.
124fn system_prompt(sandbox: &Path, model: &str) -> String {
125    let dir = sandbox.display();
126    format!(
127        "{AGENT_PROMPT}\nYou are powered by the model named {model}. The exact model ID is \
128         {model}\nHere is some useful information about the environment you are running in:\n\
129         <env>\n  Working directory: {dir}\n  Workspace root folder: {dir}\n  \
130         Is directory a git repo: no\n  Platform: linux\n</env>"
131    )
132}
133
134/// 2026-09-26: Run one agentic task until the agent stops calling tools or the
135/// turn cap is reached.
136pub async fn run_task(
137    handle: &crate::plugin::PluginHandle,
138    cfg: &AgentConfig,
139    prompt: &str,
140) -> Result<Transcript> {
141    let mut transcript = Transcript::default();
142    let outcome = agent_loop(handle, cfg, prompt, &mut transcript).await;
143    // 2026-09-26: Reap on every path, including a transport error, so nothing the
144    // agent started outlives the iteration.
145    reap(&cfg.sandbox).await;
146    outcome.map(|()| transcript)
147}
148
149/// 2026-09-26: SIGKILL every process, other than this one, whose working
150/// directory is inside the sandbox. A server started with `setsid`, as the
151/// prompt asks, is not our child, so `kill_on_drop` cannot reach it.
152/// `run_tier.sh` reaps by working directory the same way. Without `/proc` this
153/// does nothing.
154async fn reap(sandbox: &Path) {
155    let real = std::fs::canonicalize(sandbox).unwrap_or_else(|_| sandbox.to_path_buf());
156    let Ok(entries) = std::fs::read_dir("/proc") else {
157        return;
158    };
159    let me = std::process::id().to_string();
160    let victims: Vec<String> = entries
161        .flatten()
162        .map(|e| e.file_name().to_string_lossy().into_owned())
163        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) && *n != me)
164        .filter(|pid| {
165            std::fs::read_link(format!("/proc/{pid}/cwd")).is_ok_and(|c| c.starts_with(&real))
166        })
167        .collect();
168    if !victims.is_empty() {
169        let _ = tokio::process::Command::new("kill")
170            .arg("-9")
171            .args(&victims)
172            .stdout(Stdio::null())
173            .stderr(Stdio::null())
174            .status()
175            .await;
176    }
177}
178
179async fn agent_loop(
180    handle: &crate::plugin::PluginHandle,
181    cfg: &AgentConfig,
182    prompt: &str,
183    transcript: &mut Transcript,
184) -> Result<()> {
185    let target = handle.target();
186    let mut messages = vec![
187        json!({"role": "system", "content": system_prompt(&cfg.sandbox, &target.model)}),
188        json!({"role": "user", "content": prompt}),
189    ];
190    let tools = tool_schema();
191    let trace = trace::Trace::start(&cfg.sandbox, prompt);
192
193    for turn in 0..cfg.max_turns {
194        handle.check_cancelled()?;
195        handle.status(format!("agent turn {}/{}", turn + 1, cfg.max_turns));
196        compact(&mut messages);
197        let body = request_body(&target.model, &messages, &tools, cfg.max_tokens);
198        let outcome = crate::http::chat_stream(target, &body, cfg.request_timeout).await?;
199        transcript.turns = turn + 1;
200        transcript.final_text = outcome.text.clone();
201        transcript.completion_tokens += outcome.completion_tokens;
202        trace.turn(turn, &outcome);
203
204        if outcome.tool_calls.is_empty() {
205            // 2026-09-26: A turn that stopped at `max_tokens` did not finish. Its
206            // text goes back as the assistant turn, followed by a user message
207            // saying it was cut off, and the loop continues.
208            if was_cut_off(&outcome) {
209                transcript.truncated_turns += 1;
210                messages.push(json!({"role": "assistant", "content": outcome.text}));
211                messages.push(json!({"role": "user", "content":
212                    "Your previous message was cut off at the output limit before you \
213                     finished. Do not repeat it. Continue from where it stopped, and make \
214                     the tool call you intended."}));
215                continue;
216            }
217            // 2026-09-26: A turn with tool-call syntax in its text and no parsed
218            // call is re-asked instead of ending the run.
219            if tools::emitted_unparsed_call(&outcome) {
220                transcript.unparsed_call_turns += 1;
221                messages.push(json!({"role": "assistant", "content": outcome.text}));
222                messages.push(json!({"role": "user", "content":
223                    "Your previous message contained tool-call syntax in the message body, so \
224                     no tool actually ran. Re-issue exactly that one call as a real tool call, \
225                     with nothing else in the message. If you are finished, say so in plain \
226                     text with no tool-call syntax."}));
227                continue;
228            }
229            return Ok(());
230        }
231
232        messages.push(assistant_message(&outcome, turn));
233        for (i, call) in outcome.tool_calls.iter().enumerate() {
234            handle.check_cancelled()?;
235            transcript.tool_calls += 1;
236            // 2026-09-26: A tool error becomes the tool result; it does not end
237            // the run.
238            let content = match tools::execute(cfg, call, &mut transcript.commands).await {
239                Ok(text) => text,
240                Err(e) => format!("error: {e:#}"),
241            };
242            let content = truncate(&content);
243            trace.result(&call.name, &content);
244            messages.push(json!({"role": "tool", "content": content,
245                "tool_call_id": call_id(turn, i)}));
246        }
247    }
248    transcript.hit_turn_cap = true;
249    Ok(())
250}
251
252/// 2026-09-26: True when the turn has no tool call and stopped with
253/// `finish_reason` `length`: it ran out of room rather than finishing.
254fn was_cut_off(outcome: &crate::http::ChatOutcome) -> bool {
255    outcome.tool_calls.is_empty() && outcome.finish_reason.as_deref() == Some("length")
256}
257
258/// 2026-09-26: One chat request. `the_gate_request_pins_sampling_messages_and_tools`
259/// (agent_tests.rs) asserts its fields.
260fn request_body(model: &str, messages: &[Value], tools: &Value, max_tokens: usize) -> Value {
261    let mut body = json!({
262        "model": model, "stream": true,
263        "max_tokens": max_tokens, "messages": messages,
264        "tools": tools, "tool_choice": "auto",
265    });
266    // 2026-09-26: With METRALE_AGENTIC_SAMPLING=model-card the request carries no
267    // temperature or seed, so the server's defaults apply.
268    if std::env::var("METRALE_AGENTIC_SAMPLING").as_deref() != Ok("model-card") {
269        body["temperature"] = json!(TEMPERATURE);
270        body["seed"] = json!(SEED);
271    }
272    // 2026-09-26: Only `preserve_thinking` is sent; other template kwargs such as
273    // `reasoning_effort` are left to the server.
274    if preserve_thinking() {
275        body["chat_template_kwargs"] = json!({"preserve_thinking": true});
276    }
277    body
278}
279
280#[cfg(test)]
281#[path = "agent_loop_tests.rs"]
282mod loop_tests;
283#[cfg(test)]
284#[path = "agent_tests.rs"]
285mod tests;
286#[cfg(test)]
287#[path = "agent_truncation_tests.rs"]
288mod truncation_tests;