Add truncation hint on truncated exec output. (#4740)

When truncating output, add a hint of the total number of lines
This commit is contained in:
Ahmed Ibrahim
2025-10-04 20:29:07 -07:00
committed by GitHub
parent 356ea6ea34
commit cc2f4aafd7
3 changed files with 77 additions and 37 deletions

View File

@@ -2520,13 +2520,19 @@ mod tests {
let out = format_exec_output_str(&exec); let out = format_exec_output_str(&exec);
// Strip truncation header if present for subsequent assertions
let body = out
.strip_prefix("Total output lines: ")
.and_then(|rest| rest.split_once("\n\n").map(|x| x.1))
.unwrap_or(out.as_str());
// Expect elision marker with correct counts // Expect elision marker with correct counts
let omitted = 400 - MODEL_FORMAT_MAX_LINES; // 144 let omitted = 400 - MODEL_FORMAT_MAX_LINES; // 144
let marker = format!("\n[... omitted {omitted} of 400 lines ...]\n\n"); let marker = format!("\n[... omitted {omitted} of 400 lines ...]\n\n");
assert!(out.contains(&marker), "missing marker: {out}"); assert!(out.contains(&marker), "missing marker: {out}");
// Validate head and tail // Validate head and tail
let parts: Vec<&str> = out.split(&marker).collect(); let parts: Vec<&str> = body.split(&marker).collect();
assert_eq!(parts.len(), 2, "expected one marker split"); assert_eq!(parts.len(), 2, "expected one marker split");
let head = parts[0]; let head = parts[0];
let tail = parts[1]; let tail = parts[1];
@@ -2562,14 +2568,19 @@ mod tests {
}; };
let out = format_exec_output_str(&exec); let out = format_exec_output_str(&exec);
assert!(out.len() <= MODEL_FORMAT_MAX_BYTES, "exceeds byte budget"); // Keep strict budget on the truncated body (excluding header)
let body = out
.strip_prefix("Total output lines: ")
.and_then(|rest| rest.split_once("\n\n").map(|x| x.1))
.unwrap_or(out.as_str());
assert!(body.len() <= MODEL_FORMAT_MAX_BYTES, "exceeds byte budget");
assert!(out.contains("omitted"), "should contain elision marker"); assert!(out.contains("omitted"), "should contain elision marker");
// Ensure head and tail are drawn from the original // Ensure head and tail are drawn from the original
assert!(full.starts_with(out.chars().take(8).collect::<String>().as_str())); assert!(full.starts_with(body.chars().take(8).collect::<String>().as_str()));
assert!( assert!(
full.ends_with( full.ends_with(
out.chars() body.chars()
.rev() .rev()
.take(8) .take(8)
.collect::<String>() .collect::<String>()

View File

@@ -162,9 +162,9 @@ pub(crate) async fn handle_container_exec_with_params(
), ),
Err(ExecError::Codex(err)) => { Err(ExecError::Codex(err)) => {
let message = format!("execution error: {err:?}"); let message = format!("execution error: {err:?}");
Err(FunctionCallError::RespondToModel( Err(FunctionCallError::RespondToModel(format_exec_output(
truncate_formatted_exec_output(&message), &message,
)) )))
} }
} }
} }
@@ -217,20 +217,34 @@ pub fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
"command timed out after {} milliseconds\n{content}", "command timed out after {} milliseconds\n{content}",
exec_output.duration.as_millis() exec_output.duration.as_millis()
); );
return truncate_formatted_exec_output(&prefixed); return format_exec_output(&prefixed);
} }
truncate_formatted_exec_output(content) format_exec_output(content)
} }
fn truncate_formatted_exec_output(content: &str) -> String { fn truncate_function_error(err: FunctionCallError) -> FunctionCallError {
match err {
FunctionCallError::RespondToModel(msg) => {
FunctionCallError::RespondToModel(format_exec_output(&msg))
}
FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)),
other => other,
}
}
fn format_exec_output(content: &str) -> String {
// Head+tail truncation for the model: show the beginning and end with an elision. // Head+tail truncation for the model: show the beginning and end with an elision.
// Clients still receive full streams; only this formatted summary is capped. // Clients still receive full streams; only this formatted summary is capped.
let total_lines = content.lines().count(); let total_lines = content.lines().count();
if content.len() <= MODEL_FORMAT_MAX_BYTES && total_lines <= MODEL_FORMAT_MAX_LINES { if content.len() <= MODEL_FORMAT_MAX_BYTES && total_lines <= MODEL_FORMAT_MAX_LINES {
return content.to_string(); return content.to_string();
} }
let output = truncate_formatted_exec_output(content, total_lines);
format!("Total output lines: {total_lines}\n\n{output}")
}
fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String {
let segments: Vec<&str> = content.split_inclusive('\n').collect(); let segments: Vec<&str> = content.split_inclusive('\n').collect();
let head_take = MODEL_FORMAT_HEAD_LINES.min(segments.len()); let head_take = MODEL_FORMAT_HEAD_LINES.min(segments.len());
let tail_take = MODEL_FORMAT_TAIL_LINES.min(segments.len().saturating_sub(head_take)); let tail_take = MODEL_FORMAT_TAIL_LINES.min(segments.len().saturating_sub(head_take));
@@ -285,32 +299,49 @@ fn truncate_formatted_exec_output(content: &str) -> String {
result result
} }
fn truncate_function_error(err: FunctionCallError) -> FunctionCallError {
match err {
FunctionCallError::RespondToModel(msg) => {
FunctionCallError::RespondToModel(truncate_formatted_exec_output(&msg))
}
FunctionCallError::Fatal(msg) => {
FunctionCallError::Fatal(truncate_formatted_exec_output(&msg))
}
other => other,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use regex_lite::Regex;
fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) {
let pattern = truncated_message_pattern(line, total_lines);
let regex = Regex::new(&pattern).unwrap_or_else(|err| {
panic!("failed to compile regex {pattern}: {err}");
});
let captures = regex
.captures(message)
.unwrap_or_else(|| panic!("message failed to match pattern {pattern}: {message}"));
let body = captures
.name("body")
.expect("missing body capture")
.as_str();
assert!(
body.len() <= MODEL_FORMAT_MAX_BYTES,
"body exceeds byte limit: {} bytes",
body.len()
);
}
fn truncated_message_pattern(line: &str, total_lines: usize) -> String {
let head_take = MODEL_FORMAT_HEAD_LINES.min(total_lines);
let tail_take = MODEL_FORMAT_TAIL_LINES.min(total_lines.saturating_sub(head_take));
let omitted = total_lines.saturating_sub(head_take + tail_take);
let escaped_line = regex_lite::escape(line);
format!(
r"(?s)^Total output lines: {total_lines}\n\n(?P<body>{escaped_line}.*\n\[\.{{3}} omitted {omitted} of {total_lines} lines \.{{3}}]\n\n.*)$",
)
}
#[test] #[test]
fn truncate_formatted_exec_output_truncates_large_error() { fn truncate_formatted_exec_output_truncates_large_error() {
let line = "very long execution error line that should trigger truncation\n"; let line = "very long execution error line that should trigger truncation\n";
let large_error = line.repeat(2_500); // way beyond both byte and line limits let large_error = line.repeat(2_500); // way beyond both byte and line limits
let truncated = truncate_formatted_exec_output(&large_error); let truncated = format_exec_output(&large_error);
assert!(truncated.len() <= MODEL_FORMAT_MAX_BYTES); let total_lines = large_error.lines().count();
assert!(truncated.starts_with(line)); assert_truncated_message_matches(&truncated, line, total_lines);
assert!(truncated.contains("[... omitted"));
assert_ne!(truncated, large_error); assert_ne!(truncated, large_error);
} }
@@ -318,13 +349,12 @@ mod tests {
fn truncate_function_error_trims_respond_to_model() { fn truncate_function_error_trims_respond_to_model() {
let line = "respond-to-model error that should be truncated\n"; let line = "respond-to-model error that should be truncated\n";
let huge = line.repeat(3_000); let huge = line.repeat(3_000);
let total_lines = huge.lines().count();
let err = truncate_function_error(FunctionCallError::RespondToModel(huge)); let err = truncate_function_error(FunctionCallError::RespondToModel(huge));
match err { match err {
FunctionCallError::RespondToModel(message) => { FunctionCallError::RespondToModel(message) => {
assert!(message.len() <= MODEL_FORMAT_MAX_BYTES); assert_truncated_message_matches(&message, line, total_lines);
assert!(message.contains("[... omitted"));
assert!(message.starts_with(line));
} }
other => panic!("unexpected error variant: {other:?}"), other => panic!("unexpected error variant: {other:?}"),
} }
@@ -334,13 +364,12 @@ mod tests {
fn truncate_function_error_trims_fatal() { fn truncate_function_error_trims_fatal() {
let line = "fatal error output that should be truncated\n"; let line = "fatal error output that should be truncated\n";
let huge = line.repeat(3_000); let huge = line.repeat(3_000);
let total_lines = huge.lines().count();
let err = truncate_function_error(FunctionCallError::Fatal(huge)); let err = truncate_function_error(FunctionCallError::Fatal(huge));
match err { match err {
FunctionCallError::Fatal(message) => { FunctionCallError::Fatal(message) => {
assert!(message.len() <= MODEL_FORMAT_MAX_BYTES); assert_truncated_message_matches(&message, line, total_lines);
assert!(message.contains("[... omitted"));
assert!(message.starts_with(line));
} }
other => panic!("unexpected error variant: {other:?}"), other => panic!("unexpected error variant: {other:?}"),
} }

View File

@@ -438,11 +438,11 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
let stdout = output_json["output"].as_str().unwrap_or_default(); let stdout = output_json["output"].as_str().unwrap_or_default();
assert!( assert!(
stdout.starts_with("command timed out after "), stdout.contains("command timed out after "),
"expected timeout prefix, got {stdout:?}" "expected timeout prefix, got {stdout:?}"
); );
let first_line = stdout.lines().next().unwrap_or_default(); let third_line = stdout.lines().nth(2).unwrap_or_default();
let duration_ms = first_line let duration_ms = third_line
.strip_prefix("command timed out after ") .strip_prefix("command timed out after ")
.and_then(|line| line.strip_suffix(" milliseconds")) .and_then(|line| line.strip_suffix(" milliseconds"))
.and_then(|value| value.parse::<u64>().ok()) .and_then(|value| value.parse::<u64>().ok())
@@ -519,7 +519,7 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> {
.expect("denied output string"); .expect("denied output string");
assert!( assert!(
output.starts_with("failed in sandbox: "), output.contains("failed in sandbox: "),
"expected sandbox error prefix, got {output:?}" "expected sandbox error prefix, got {output:?}"
); );
assert!( assert!(
@@ -605,7 +605,7 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> {
.expect("spawn failure output string"); .expect("spawn failure output string");
assert!( assert!(
output.starts_with("execution error:"), output.contains("execution error:"),
"expected execution error prefix, got {output:?}" "expected execution error prefix, got {output:?}"
); );
assert!(output.len() <= 10 * 1024); assert!(output.len() <= 10 * 1024);