use crate::conversation::message::{Message, MessageContent};
use crate::tool::ToolResult;
pub fn truncate_output(result: &mut ToolResult, tool_name: &str, context_window: usize) {
match tool_name {
"bash" => {}
"read_file" => {} "web_fetch" => truncate_generic(result, 150, 20, 40),
_ => truncate_generic(result, 200, 30, 50),
}
if tool_name != "read_file" {
const UNIVERSAL_MAX_LINES: usize = 300;
let line_count = result.output.lines().count();
if line_count > UNIVERSAL_MAX_LINES {
let lines: Vec<&str> = result.output.lines().collect();
const HEAD: usize = 50;
const TAIL: usize = 50;
let head_part = lines[..HEAD].join("\n");
let tail_part = lines[lines.len() - TAIL..].join("\n");
result.output = format!(
"{}\n\n[... {} lines omitted (universal 300-line cap) ...]\n\n{}",
head_part,
line_count - HEAD - TAIL,
tail_part,
);
}
}
let hard_char_limit = (context_window / 8).min(32_000).max(8_000);
if tool_name == "read_file" {
} else if result.output.len() > hard_char_limit {
let chars: Vec<char> = result.output.chars().collect();
let head_chars = hard_char_limit * 2 / 3;
let tail_chars = hard_char_limit / 3;
let head_part: String = chars[..head_chars.min(chars.len())].iter().collect();
let tail_part: String = chars[chars.len().saturating_sub(tail_chars)..]
.iter()
.collect();
let omitted = chars.len().saturating_sub(head_chars + tail_chars);
result.output = format!(
"{}\n\n[... {} chars omitted (universal {} char cap) ...]\n\n{}",
head_part, omitted, hard_char_limit, tail_part,
);
}
}
pub(crate) fn truncate_generic(
result: &mut ToolResult,
max_lines: usize,
head: usize,
tail: usize,
) {
let lines: Vec<&str> = result.output.lines().collect();
if lines.len() > max_lines {
let head_part: String = lines[..head].join("\n");
let tail_part: String = lines[lines.len() - tail..].join("\n");
result.output = format!(
"{}\n\n[... {} lines omitted ...]\n\n{}",
head_part,
lines.len() - head - tail,
tail_part
);
}
}
pub fn post_process_tool_results(
messages: &mut Vec<Message>,
tool_count: usize,
current_tool_name: &str,
context_window: usize,
) {
let len = messages.len();
let start = len.saturating_sub(tool_count);
let mut call_id_to_tool: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for msg in messages.iter() {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
call_id_to_tool.insert(tc.id.clone(), tc.name.clone());
}
}
}
for i in start..len {
if let MessageContent::ToolResult(ref r) = messages[i].content {
let tool_name = call_id_to_tool
.get(&r.call_id)
.map(|s| s.as_str())
.unwrap_or(current_tool_name);
let mut result = r.clone();
truncate_output(&mut result, tool_name, context_window);
messages[i].content = MessageContent::ToolResult(result);
}
}
let turn_budget = (context_window / 4).min(64_000).max(4_000);
let mut total_chars: usize = 0;
for i in start..len {
if let MessageContent::ToolResult(ref r) = messages[i].content {
total_chars += r.output.len();
}
}
if total_chars > turn_budget {
let ratio = turn_budget as f64 / total_chars as f64;
for i in start..len {
if let MessageContent::ToolResult(ref r) = messages[i].content {
let target = (r.output.len() as f64 * ratio) as usize;
if r.output.len() > target && target > 200 {
let mut result = r.clone();
let chars: Vec<char> = result.output.chars().collect();
let head = target * 2 / 3;
let tail = target / 3;
let head_part: String = chars[..head.min(chars.len())].iter().collect();
let tail_part: String =
chars[chars.len().saturating_sub(tail)..].iter().collect();
result.output = format!(
"{}\n[... trimmed to fit turn budget ...]\n{}",
head_part, tail_part,
);
messages[i].content = MessageContent::ToolResult(result);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{Message, MessageContent, Role};
use crate::tool::{ToolCall, ToolResult};
fn make_result(output: &str) -> ToolResult {
ToolResult {
call_id: "test_call".to_string(),
output: output.to_string(),
success: true,
}
}
fn make_tool_result_message(output: &str) -> Message {
Message {
role: Role::Tool,
content: MessageContent::ToolResult(make_result(output)),
}
}
fn make_atc(call_id: &str, tool_name: &str) -> Message {
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: call_id.to_string(),
name: tool_name.to_string(),
arguments: String::new(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
}
}
fn make_tool_result_with_id(call_id: &str, output: &str) -> Message {
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: call_id.to_string(),
output: output.to_string(),
success: true,
}),
}
}
#[test]
fn bash_short_output_passes_through_verbatim() {
let output: String = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let mut result = make_result(&output);
truncate_output(&mut result, "bash", 64_000);
assert_eq!(
result.output, output,
"bash output under 300 lines must not be touched"
);
}
#[test]
fn bash_huge_output_hits_universal_line_cap_only() {
let output: String = (0..500)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let mut result = make_result(&output);
truncate_output(&mut result, "bash", 64_000);
assert!(result.output.contains("line 0"), "head must be preserved");
assert!(result.output.contains("line 499"), "tail must be preserved");
assert!(
result.output.contains("lines omitted"),
"omission marker required"
);
assert!(result.output.lines().count() <= 110);
}
#[test]
fn bash_chinese_stderr_survives_truncation() {
let output: String = (0..50)
.map(|_| "编译失败:找不到符号".to_string())
.collect::<Vec<_>>()
.join("\n");
let mut result = make_result(&output);
truncate_output(&mut result, "bash", 64_000);
assert_eq!(result.output.matches("编译失败").count(), 50);
}
#[test]
fn truncate_generic_under_limit_unchanged() {
let output = "line1\nline2\nline3\n";
let mut result = make_result(output);
truncate_generic(&mut result, 200, 30, 50);
assert_eq!(result.output, output);
}
#[test]
fn truncate_generic_over_limit_has_head_and_tail() {
let lines: Vec<String> = (0..300).map(|i| format!("line {}", i)).collect();
let output = lines.join("\n");
let mut result = make_result(&output);
truncate_generic(&mut result, 200, 30, 50);
assert!(result.output.len() < output.len());
assert!(result.output.contains("line 0"));
assert!(result.output.contains("line 299"));
assert!(result.output.contains("lines omitted"));
}
#[test]
fn truncate_output_hard_char_limit() {
let output = "x".repeat(20000);
let mut result = make_result(&output);
truncate_output(&mut result, "unknown_tool", 16000);
assert!(
result.output.len() <= 8_500,
"got {} chars",
result.output.len()
);
assert!(
result.output.contains("chars omitted"),
"got: {}",
result.output
);
}
#[test]
fn truncate_output_universal_line_cap() {
let output: String = (0..500)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let mut result = make_result(&output);
truncate_output(&mut result, "unknown_tool", 64_000);
let line_count = result.output.lines().count();
assert!(
line_count <= 110,
"got {} lines, expected ≤ 110",
line_count
);
assert!(result.output.contains("lines omitted"));
}
#[test]
fn truncate_output_caps_never_grow_with_huge_window() {
let output = "x".repeat(200_000);
let mut result = make_result(&output);
truncate_output(&mut result, "unknown_tool", 1_000_000);
assert!(
result.output.len() <= 33_000,
"single tool output should never exceed 32K chars, got {}",
result.output.len()
);
}
#[test]
fn post_process_truncates_results() {
let large_output = "x".repeat(20000);
let mut messages = vec![make_tool_result_message(&large_output)];
post_process_tool_results(&mut messages, 1, "unknown_tool", 16000);
assert!(matches!(messages[0].content, MessageContent::ToolResult(_)));
if let MessageContent::ToolResult(ref r) = messages[0].content {
assert!(r.output.len() <= 8_500);
}
}
#[test]
fn post_process_keeps_small_results_unchanged() {
let small_output = "short output";
let mut messages = vec![make_tool_result_message(small_output)];
post_process_tool_results(&mut messages, 1, "bash", 16000);
assert!(matches!(messages[0].content, MessageContent::ToolResult(_)));
if let MessageContent::ToolResult(ref r) = messages[0].content {
assert_eq!(r.output, "short output");
}
}
#[test]
fn post_process_keys_truncation_by_each_result_tool_not_current() {
let file_content: String = (0..400)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let original_line_count = file_content.lines().count();
let mut messages = vec![
make_atc("rf1", "read_file"),
make_tool_result_with_id("rf1", &file_content),
];
post_process_tool_results(&mut messages, 2, "bash", 128_000);
if let MessageContent::ToolResult(ref r) = messages[1].content {
assert_eq!(
r.output.lines().count(),
original_line_count,
"read_file content must stay intact when current_tool_name \
is a different tool — got {} lines (expected {})",
r.output.lines().count(),
original_line_count,
);
} else {
panic!("expected ToolResult at index 1");
}
}
}