fabro/crates/coding-agent-loop/src/truncation.rs
Bryan Helmkamp db32f00cd0 Simplify coding-agent-loop: deduplicate mocks, narrow traits, type events
- Extract shared test infrastructure (MockExecutionEnvironment, TestProfile,
  MockLlmProvider) replacing 11 duplicate mock implementations across tests
- Deduplicate tool execution logic between sequential and parallel paths
- Narrow ProviderProfile trait from 14 to 7 required methods via
  ProfileCapabilities struct and default implementations
- Replace stringly-typed HashMap event data with typed EventData enum
- Extract shared assemble_system_prompt helper and register_subagent_tools
  default method, eliminating copy-paste across all 3 profiles
- Replace fragile shell-based glob with glob crate, fix rg detection
- Add delete_file to ExecutionEnvironment, wire git context into env block
- Remove dead code (AgentError::Io, count_turns, trivial derived-trait tests)
- Use match-based lookups in truncation instead of per-call HashMap allocation

Net reduction: -1,401 lines across 20 files. All 180 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:34:29 -04:00

237 lines
7.7 KiB
Rust

use crate::config::SessionConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TruncationMode {
HeadTail,
Tail,
}
fn default_char_limit(tool_name: &str) -> Option<usize> {
match tool_name {
"read_file" => Some(50_000),
"shell" => Some(30_000),
"grep" => Some(20_000),
"glob" => Some(20_000),
"edit_file" => Some(10_000),
"write_file" => Some(1_000),
"apply_patch" => Some(10_000),
"spawn_agent" => Some(20_000),
_ => None,
}
}
fn default_line_limit(tool_name: &str) -> Option<usize> {
match tool_name {
"shell" => Some(256),
"grep" => Some(200),
"glob" => Some(500),
_ => None,
}
}
fn default_truncation_mode(tool_name: &str) -> TruncationMode {
match tool_name {
"grep" | "glob" | "edit_file" | "apply_patch" | "write_file" => TruncationMode::Tail,
_ => TruncationMode::HeadTail,
}
}
pub fn truncate_output(output: &str, max_chars: usize, mode: TruncationMode) -> String {
if output.len() <= max_chars {
return output.to_string();
}
let removed = output.len() - max_chars;
match mode {
TruncationMode::HeadTail => {
let half = max_chars / 2;
let head = &output[..half];
let tail = &output[output.len() - half..];
format!(
"{head}\n\n[WARNING: Tool output was truncated. {removed} characters were removed from the middle. \
The full output is available in the event stream. \
If you need to see specific parts, re-run the tool with more targeted parameters.]\n\n{tail}"
)
}
TruncationMode::Tail => {
let tail = &output[output.len() - max_chars..];
format!(
"[WARNING: Tool output was truncated. First {removed} characters were removed. \
The full output is available in the event stream.]\n\n{tail}"
)
}
}
}
pub fn truncate_lines(output: &str, max_lines: usize) -> String {
let lines: Vec<&str> = output.lines().collect();
if lines.len() <= max_lines {
return output.to_string();
}
let half = max_lines / 2;
let head: Vec<&str> = lines[..half].to_vec();
let tail: Vec<&str> = lines[lines.len() - half..].to_vec();
let omitted = lines.len() - max_lines;
format!(
"{}\n\n[... {omitted} lines omitted ...]\n\n{}",
head.join("\n"),
tail.join("\n")
)
}
pub fn truncate_tool_output(output: &str, tool_name: &str, config: &SessionConfig) -> String {
let mode = default_truncation_mode(tool_name);
// Char truncation first
let char_limit = config
.tool_output_limits
.get(tool_name)
.copied()
.or_else(|| default_char_limit(tool_name));
let after_chars = match char_limit {
Some(limit) => truncate_output(output, limit, mode),
None => output.to_string(),
};
// Then line truncation
let line_limit = config
.tool_line_limits
.get(tool_name)
.copied()
.or_else(|| default_line_limit(tool_name));
match line_limit {
Some(limit) => truncate_lines(&after_chars, limit),
None => after_chars,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn under_limit_passthrough_chars() {
let output = "short output";
let result = truncate_output(output, 100, TruncationMode::HeadTail);
assert_eq!(result, output);
}
#[test]
fn under_limit_passthrough_lines() {
let output = "line1\nline2\nline3";
let result = truncate_lines(output, 10);
assert_eq!(result, output);
}
#[test]
fn head_tail_split() {
let output = "a".repeat(100);
let result = truncate_output(&output, 40, TruncationMode::HeadTail);
assert!(result.contains(&"a".repeat(20)));
assert!(result.contains("Tool output was truncated"));
assert!(result.contains("60 characters were removed from the middle"));
}
#[test]
fn tail_mode() {
let output = format!("{}BBB", "A".repeat(100));
let result = truncate_output(&output, 10, TruncationMode::Tail);
assert!(result.contains("Tool output was truncated"));
assert!(result.contains("First 93 characters were removed"));
assert!(result.ends_with("AAAAAAABBB"));
}
#[test]
fn line_truncation_splits_head_tail() {
let lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
let output = lines.join("\n");
let result = truncate_lines(&output, 6);
assert!(result.contains("line 1"));
assert!(result.contains("line 3"));
assert!(result.contains("line 18"));
assert!(result.contains("line 20"));
assert!(result.contains("... 14 lines omitted ..."));
}
#[test]
fn char_truncation_before_lines() {
// Create an output that is large in chars and many lines
let long_line = "x".repeat(50_000);
let output = format!("{long_line}\n{long_line}");
let config = SessionConfig::default();
let result = truncate_tool_output(&output, "shell", &config);
// Should have been char-truncated first (30k limit for shell)
assert!(result.len() < output.len());
}
#[test]
fn config_override_char_limit() {
let output = "x".repeat(5000);
let mut config = SessionConfig::default();
config
.tool_output_limits
.insert("my_tool".into(), 100);
let result = truncate_tool_output(&output, "my_tool", &config);
assert!(result.len() < output.len());
assert!(result.contains("Tool output was truncated"));
}
#[test]
fn config_override_line_limit() {
let lines: Vec<String> = (1..=100).map(|i| format!("line {i}")).collect();
let output = lines.join("\n");
let mut config = SessionConfig::default();
config.tool_line_limits.insert("my_tool".into(), 10);
let result = truncate_tool_output(&output, "my_tool", &config);
assert!(result.contains("lines omitted"));
}
#[test]
fn unknown_tool_no_truncation() {
let output = "x".repeat(200);
let config = SessionConfig::default();
let result = truncate_tool_output(&output, "unknown_tool", &config);
assert_eq!(result, output);
}
#[test]
fn default_char_limits_match_spec() {
assert_eq!(default_char_limit("read_file"), Some(50_000));
assert_eq!(default_char_limit("shell"), Some(30_000));
assert_eq!(default_char_limit("grep"), Some(20_000));
assert_eq!(default_char_limit("glob"), Some(20_000));
assert_eq!(default_char_limit("edit_file"), Some(10_000));
assert_eq!(default_char_limit("write_file"), Some(1_000));
assert_eq!(default_char_limit("apply_patch"), Some(10_000));
assert_eq!(default_char_limit("spawn_agent"), Some(20_000));
assert_eq!(default_char_limit("unknown"), None);
}
#[test]
fn default_line_limits_match_spec() {
assert_eq!(default_line_limit("shell"), Some(256));
assert_eq!(default_line_limit("grep"), Some(200));
assert_eq!(default_line_limit("glob"), Some(500));
assert_eq!(default_line_limit("unknown"), None);
}
#[test]
fn exact_limit_not_truncated() {
let output = "x".repeat(100);
let result = truncate_output(&output, 100, TruncationMode::HeadTail);
assert_eq!(result, output);
}
#[test]
fn exact_line_limit_not_truncated() {
let lines: Vec<String> = (1..=10).map(|i| format!("line {i}")).collect();
let output = lines.join("\n");
let result = truncate_lines(&output, 10);
assert_eq!(result, output);
}
}