Skip to content

Commit 908afb7

Browse files
authored
Merge pull request #134 from TransformerOptimus/feat/bench-runner-tool-policy
Bench-runner tool fixes: timeouts, ignore-list, result caps (ToolPolicy)
2 parents bd712a3 + b5815ae commit 908afb7

13 files changed

Lines changed: 580 additions & 42 deletions

File tree

crates/agent/src/agent/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ pub struct AgentConfig {
5252
/// before editing it (keyed by `(session_id, turn)`), enabling per-turn undo.
5353
/// `None` disables checkpoint capture (bench/tests).
5454
pub checkpoint_dir: Option<PathBuf>,
55+
/// Per-binary tool behavior knobs (timeouts, ignore-list, result caps).
56+
/// Default = permissive app behavior; `bench-runner` sets `ToolPolicy::bench()`.
57+
pub tool_policy: crate::tool::ToolPolicy,
5558
}
5659

5760
impl AgentConfig {
@@ -71,6 +74,7 @@ impl AgentConfig {
7174
subagents: None,
7275
subagent_inheritance: None,
7376
checkpoint_dir: None,
77+
tool_policy: crate::tool::ToolPolicy::default(),
7478
}
7579
}
7680

crates/agent/src/agent/loop_.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ impl AgentLoop {
483483
let approval_ref = self.approval_handler.clone();
484484
let checkpoint_dir = self.config.checkpoint_dir.clone();
485485
let checkpoint_turn = iteration + self.iteration_offset;
486+
let tool_policy = self.config.tool_policy.clone();
486487

487488
join_set.spawn(async move {
488489
let result = execute_tool_call_impl(
@@ -497,6 +498,7 @@ impl AgentLoop {
497498
approval_ref.as_deref(),
498499
checkpoint_dir,
499500
checkpoint_turn,
501+
tool_policy,
500502
)
501503
.await;
502504
(idx, tool_call_id, result)
@@ -1037,6 +1039,7 @@ async fn execute_tool_call_impl(
10371039
approval_handler: Option<&dyn ApprovalHandler>,
10381040
checkpoint_dir: Option<std::path::PathBuf>,
10391041
checkpoint_turn: u32,
1042+
tool_policy: crate::tool::ToolPolicy,
10401043
) -> ToolResult {
10411044
// Parse arguments first — if this fails, emit a basic ToolStart before the error ToolEnd
10421045
let args: serde_json::Value = match serde_json::from_str(arguments_json) {
@@ -1206,6 +1209,7 @@ async fn execute_tool_call_impl(
12061209
tool_call_id: tool_call_id.to_string(),
12071210
checkpoint_dir,
12081211
checkpoint_turn,
1212+
policy: tool_policy,
12091213
};
12101214

12111215
let mut result = match tool.execute(args, &ctx).await {

crates/agent/src/skills/tool.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ mod tests {
132132
tool_call_id: "tc_1".into(),
133133
checkpoint_dir: None,
134134
checkpoint_turn: 0,
135+
policy: crate::tool::ToolPolicy::default(),
135136
};
136137
let result = tool
137138
.execute(json!({"name": "nope"}), &ctx)
@@ -154,6 +155,7 @@ mod tests {
154155
tool_call_id: "tc_1".into(),
155156
checkpoint_dir: None,
156157
checkpoint_turn: 0,
158+
policy: crate::tool::ToolPolicy::default(),
157159
};
158160
let result = tool
159161
.execute(json!({"name": "hello"}), &ctx)
@@ -195,6 +197,7 @@ mod tests {
195197
tool_call_id: "tc_1".into(),
196198
checkpoint_dir: None,
197199
checkpoint_turn: 0,
200+
policy: crate::tool::ToolPolicy::default(),
198201
};
199202
let result = tool
200203
.execute(json!({"name": "meta"}), &ctx)
@@ -219,6 +222,7 @@ mod tests {
219222
tool_call_id: "tc_1".into(),
220223
checkpoint_dir: None,
221224
checkpoint_turn: 0,
225+
policy: crate::tool::ToolPolicy::default(),
222226
};
223227
let err = tool.execute(json!({}), &ctx).await.unwrap_err();
224228
assert!(err.0.contains("Missing"));

crates/agent/src/subagents/tool.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ impl Tool for SpawnSubagentTool {
245245
subagents: None,
246246
subagent_inheritance: None, // depth-1 enforced
247247
checkpoint_dir: self.inherit.checkpoint_dir.clone(),
248+
// Subagents are only spawned by the desktop app, which runs the permissive
249+
// default policy; the headless bench-runner never spawns them. Default keeps
250+
// app behavior unchanged.
251+
tool_policy: crate::tool::ToolPolicy::default(),
248252
};
249253

250254
// Child event channel + drain. Per DEC-1 (in docs/subagent-bugs-and-fixes.md),

crates/agent/src/tool/bash.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,18 @@ impl Tool for BashTool {
5656
.and_then(|v| v.as_str())
5757
.unwrap_or("Running command");
5858

59-
let timeout_ms = args
59+
let mut timeout_ms = args
6060
.get("timeout")
6161
.and_then(|v| v.as_u64())
6262
.unwrap_or(DEFAULT_TIMEOUT_MS);
6363

64+
// Bench policy clamps the model-supplied timeout to a hard ceiling so a single
65+
// runaway command can't burn the whole per-cell wall. No-op under the default
66+
// (app) policy, which keeps the timeout fully model-controlled.
67+
if let Some(ceiling) = ctx.policy.bash_timeout_ceiling_ms {
68+
timeout_ms = timeout_ms.min(ceiling);
69+
}
70+
6471
// Emit status event
6572
let _ = ctx.event_tx.send(AgentEvent::ToolStatus {
6673
session_id: ctx.session_id.clone(),
@@ -231,6 +238,7 @@ mod tests {
231238
tool_call_id: "tc_1".into(),
232239
checkpoint_dir: None,
233240
checkpoint_turn: 0,
241+
policy: crate::tool::ToolPolicy::default(),
234242
};
235243

236244
let tool = BashTool;
@@ -254,6 +262,34 @@ mod tests {
254262
assert!(result.output.contains("cancelled"));
255263
}
256264

265+
#[tokio::test]
266+
async fn test_bench_policy_clamps_timeout() {
267+
// Under bench policy the ceiling is 300s. A command that exceeds a *small*
268+
// model-supplied timeout still times out (the clamp is a min(), so the
269+
// smaller value wins) — this proves the clamp path doesn't break the
270+
// existing timeout behavior.
271+
let dir = tempdir().unwrap();
272+
let tool = BashTool;
273+
let ctx = ToolContext::test_context_bench(dir.path());
274+
275+
let result = tool
276+
.execute(
277+
json!({ "command": "sleep 10", "description": "sleep", "timeout": 100 }),
278+
&ctx,
279+
)
280+
.await
281+
.unwrap();
282+
283+
assert!(result.is_error);
284+
assert!(result.output.contains("timed out"));
285+
}
286+
287+
#[test]
288+
fn test_bench_policy_ceiling_value() {
289+
assert_eq!(crate::tool::ToolPolicy::bench().bash_timeout_ceiling_ms, Some(300_000));
290+
assert_eq!(crate::tool::ToolPolicy::default().bash_timeout_ceiling_ms, None);
291+
}
292+
257293
#[tokio::test]
258294
async fn test_working_dir() {
259295
let dir = tempdir().unwrap();

crates/agent/src/tool/codebase_graph.rs

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use crate::context_engine::ContextEngineApi;
77
use crate::error::ToolError;
88
use super::{Tool, ToolContext, ToolResult};
99

10+
/// Max rows rendered per graph section (callers / deps / related) under bench policy.
11+
const GRAPH_SECTION_CAP: usize = 50;
12+
1013
pub struct CodebaseGraphTool {
1114
context_engine: Arc<dyn ContextEngineApi>,
1215
}
@@ -73,7 +76,13 @@ impl Tool for CodebaseGraphTool {
7376
));
7477
}
7578

76-
let _ = ctx; // ToolContext not needed for graph queries (no working-copy overlay)
79+
// Bench policy: cap each rendered section so a high-degree node doesn't dump
80+
// hundreds of rows into the context. No-op (unbounded) under the app policy.
81+
let section_cap = if ctx.policy.codebase_result_caps {
82+
GRAPH_SECTION_CAP
83+
} else {
84+
usize::MAX
85+
};
7786

7887
log::info!(
7988
"[codebase_graph] query={:?}, function_name={:?}, file_path={:?}, query_type={:?}",
@@ -165,50 +174,47 @@ impl Tool for CodebaseGraphTool {
165174

166175
if show_callers && !callers.is_empty() {
167176
output.push_str("## Blast Radius (upstream callers)\n\n");
168-
for (i, item) in callers.iter().enumerate() {
169-
output.push_str(&format!(
170-
"{}. {} ({}, depth: {})\n",
171-
i + 1,
172-
item.name,
173-
item.file_path,
174-
item.depth,
175-
));
176-
}
177+
render_section(&mut output, &callers, section_cap);
177178
output.push('\n');
178179
}
179180

180181
if show_deps && !dependencies.is_empty() {
181182
output.push_str("## Dependencies (downstream)\n\n");
182-
for (i, item) in dependencies.iter().enumerate() {
183-
output.push_str(&format!(
184-
"{}. {} ({}, depth: {})\n",
185-
i + 1,
186-
item.name,
187-
item.file_path,
188-
item.depth,
189-
));
190-
}
183+
render_section(&mut output, &dependencies, section_cap);
191184
output.push('\n');
192185
}
193186

194187
if !other.is_empty() && query_type.is_none() {
195188
output.push_str("## Related\n\n");
196-
for (i, item) in other.iter().enumerate() {
197-
output.push_str(&format!(
198-
"{}. {} ({}, depth: {})\n",
199-
i + 1,
200-
item.name,
201-
item.file_path,
202-
item.depth,
203-
));
204-
}
189+
render_section(&mut output, &other, section_cap);
205190
output.push('\n');
206191
}
207192

208193
Ok(ToolResult::success(output))
209194
}
210195
}
211196

197+
/// Render up to `cap` graph rows; append a `… and N more` line when the section
198+
/// is truncated so the model knows results were withheld.
199+
fn render_section(
200+
output: &mut String,
201+
items: &[&crate::context_engine::GraphResultItem],
202+
cap: usize,
203+
) {
204+
for (i, item) in items.iter().take(cap).enumerate() {
205+
output.push_str(&format!(
206+
"{}. {} ({}, depth: {})\n",
207+
i + 1,
208+
item.name,
209+
item.file_path,
210+
item.depth,
211+
));
212+
}
213+
if items.len() > cap {
214+
output.push_str(&format!("… and {} more\n", items.len() - cap));
215+
}
216+
}
217+
212218
#[cfg(test)]
213219
mod tests {
214220
use super::*;
@@ -405,6 +411,57 @@ mod tests {
405411
assert!(result.output.contains("'query' or 'function_name'"));
406412
}
407413

414+
#[tokio::test]
415+
async fn test_bench_policy_caps_section() {
416+
let results: Vec<GraphResultItem> = (0..60)
417+
.map(|i| GraphResultItem {
418+
name: format!("caller_{i}"),
419+
file_path: format!("src/f{i}.go"),
420+
chunk_id: format!("g{i}"),
421+
depth: 1,
422+
direction: Some("caller".into()),
423+
})
424+
.collect();
425+
let mock = MockContextEngine::with_graph_results(results);
426+
let tool = CodebaseGraphTool::new(Arc::new(mock));
427+
let dir = tempfile::tempdir().unwrap();
428+
let ctx = ToolContext::test_context_bench(dir.path());
429+
430+
let result = tool
431+
.execute(json!({"function_name": "f", "query_type": "blast_radius"}), &ctx)
432+
.await
433+
.unwrap();
434+
assert!(!result.is_error);
435+
assert!(result.output.contains("caller_49"), "row 50 (index 49) should render");
436+
assert!(!result.output.contains("caller_50"), "row 51 must be capped under bench policy");
437+
assert!(result.output.contains("… and 10 more"));
438+
}
439+
440+
#[tokio::test]
441+
async fn test_default_policy_no_section_cap() {
442+
let results: Vec<GraphResultItem> = (0..60)
443+
.map(|i| GraphResultItem {
444+
name: format!("caller_{i}"),
445+
file_path: format!("src/f{i}.go"),
446+
chunk_id: format!("g{i}"),
447+
depth: 1,
448+
direction: Some("caller".into()),
449+
})
450+
.collect();
451+
let mock = MockContextEngine::with_graph_results(results);
452+
let tool = CodebaseGraphTool::new(Arc::new(mock));
453+
let dir = tempfile::tempdir().unwrap();
454+
let ctx = ToolContext::test_context(dir.path());
455+
456+
let result = tool
457+
.execute(json!({"function_name": "f", "query_type": "blast_radius"}), &ctx)
458+
.await
459+
.unwrap();
460+
assert!(!result.is_error);
461+
assert!(result.output.contains("caller_59"), "default policy must render all rows");
462+
assert!(!result.output.contains("more"));
463+
}
464+
408465
#[tokio::test]
409466
async fn test_graph_function_name_only() {
410467
let mock = MockContextEngine::with_graph_results(make_caller_results());

0 commit comments

Comments
 (0)