metrale_bench/benchmarks/agentic/
agent.rs1use 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
40pub(super) const MAX_TOOL_OUTPUT: usize = 8192;
43
44const HISTORY_BUDGET: usize = 96_000;
47
48const LIVE_TOOL_RESULTS: usize = 4;
50
51const LIVE_REASONING: usize = 4;
54
55const TEMPERATURE: f64 = 0.0;
59
60const SEED: u64 = 0;
62
63pub(super) const DRAIN_GRACE: Duration = Duration::from_secs(2);
67
68const 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#[derive(Default)]
90pub struct Transcript {
91 pub commands: Vec<String>,
94 pub turns: usize,
95 pub tool_calls: usize,
96 pub hit_turn_cap: bool,
99 pub truncated_turns: usize,
102 pub unparsed_call_turns: usize,
105 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 pub cargo_target_dir: Option<PathBuf>,
119}
120
121fn 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
134pub 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 reap(&cfg.sandbox).await;
146 outcome.map(|()| transcript)
147}
148
149async 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 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 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 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
252fn was_cut_off(outcome: &crate::http::ChatOutcome) -> bool {
255 outcome.tool_calls.is_empty() && outcome.finish_reason.as_deref() == Some("length")
256}
257
258fn 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 if std::env::var("METRALE_AGENTIC_SAMPLING").as_deref() != Ok("model-card") {
269 body["temperature"] = json!(TEMPERATURE);
270 body["seed"] = json!(SEED);
271 }
272 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;