fix: deliver truncation notice as separate content block (#7899)

This commit is contained in:
jeffa-block
2026-03-27 08:23:03 +11:00
committed by GitHub
parent 9a226421a1
commit abdeaee0ff
@@ -20,6 +20,20 @@ const OUTPUT_PREVIEW_LINES: usize = 50;
const OUTPUT_SLOTS: usize = 8; const OUTPUT_SLOTS: usize = 8;
/// Result of truncating command output.
struct TruncateResult {
/// The (possibly truncated) text to display.
text: String,
/// When output was truncated, the path where the full output was saved
/// and a human-readable reason for the truncation.
truncation: Option<TruncationInfo>,
}
struct TruncationInfo {
path: PathBuf,
reason: String,
}
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ShellParams { pub struct ShellParams {
pub command: String, pub command: String,
@@ -165,37 +179,56 @@ impl ShellTool {
let output_dir = self.output_dir.path(); let output_dir = self.output_dir.path();
let slot = self.call_index.fetch_add(1, Ordering::Relaxed) % OUTPUT_SLOTS; let slot = self.call_index.fetch_add(1, Ordering::Relaxed) % OUTPUT_SLOTS;
let truncated_stdout = if raw_stdout.is_empty() { let stdout_result = if raw_stdout.is_empty() {
String::new() TruncateResult {
text: String::new(),
truncation: None,
}
} else { } else {
match truncate_output(&raw_stdout, &format!("stdout-{slot}"), output_dir) { match truncate_output(&raw_stdout, &format!("stdout-{slot}"), output_dir) {
Ok(t) => t, Ok(r) => r,
Err(error) => return Self::error_result(&error, None), Err(error) => return Self::error_result(&error, None),
} }
}; };
let truncated_stderr = if raw_stderr.is_empty() { let stderr_result = if raw_stderr.is_empty() {
String::new() TruncateResult {
text: String::new(),
truncation: None,
}
} else { } else {
match truncate_output(&raw_stderr, &format!("stderr-{slot}"), output_dir) { match truncate_output(&raw_stderr, &format!("stderr-{slot}"), output_dir) {
Ok(t) => t, Ok(r) => r,
Err(error) => return Self::error_result(&error, None), Err(error) => return Self::error_result(&error, None),
} }
}; };
let shell_output = ShellOutput { let shell_output = ShellOutput {
stdout: truncated_stdout, stdout: stdout_result.text,
stderr: truncated_stderr, stderr: stderr_result.text,
exit_code: execution.exit_code, exit_code: execution.exit_code,
timed_out: execution.timed_out, timed_out: execution.timed_out,
output_truncated: execution.output_truncated, output_truncated: execution.output_truncated,
output_collection_error: execution.output_collection_error.clone(), output_collection_error: execution.output_collection_error.clone(),
}; };
let structured_content = serde_json::to_value(&shell_output).ok(); let structured_content = serde_json::to_value(&shell_output).ok();
let mut rendered = match render_output(&interleaved, &format!("output-{slot}"), output_dir) let render_result = match render_output(&interleaved, &format!("output-{slot}"), output_dir)
{ {
Ok(rendered) => rendered, Ok(r) => r,
Err(error) => return Self::error_result(&error, None), Err(error) => return Self::error_result(&error, None),
}; };
let mut rendered = render_result.text;
// Collect truncation notices from stdout, stderr, and interleaved output.
// These are delivered as a separate Content block so the model sees them as
// instructions rather than part of the command's data output.
let truncation_notices: Vec<String> = [
&stdout_result.truncation,
&stderr_result.truncation,
&render_result.truncation,
]
.iter()
.filter_map(|t| t.as_ref().map(truncation_notice))
.collect();
let is_error = if execution.timed_out { let is_error = if execution.timed_out {
if let Some(timeout_secs) = params.timeout_secs { if let Some(timeout_secs) = params.timeout_secs {
@@ -228,13 +261,20 @@ impl ShellTool {
if let Some(code) = execution.exit_code.filter(|c| *c != 0) { if let Some(code) = execution.exit_code.filter(|c| *c != 0) {
rendered.push_str(&format!("\n\nCommand exited with code {code}")); rendered.push_str(&format!("\n\nCommand exited with code {code}"));
} }
let mut result = let mut error_blocks = vec![Content::text(rendered).with_priority(0.0)];
CallToolResult::error(vec![Content::text(rendered).with_priority(0.0)]); if !truncation_notices.is_empty() {
error_blocks.push(Content::text(truncation_notices.join("\n")).with_priority(0.0));
}
let mut result = CallToolResult::error(error_blocks);
result.structured_content = structured_content; result.structured_content = structured_content;
return result; return result;
} }
let mut result = CallToolResult::success(vec![Content::text(rendered).with_priority(0.0)]); let mut content_blocks = vec![Content::text(rendered).with_priority(0.0)];
if !truncation_notices.is_empty() {
content_blocks.push(Content::text(truncation_notices.join("\n")).with_priority(0.0));
}
let mut result = CallToolResult::success(content_blocks);
result.structured_content = structured_content; result.structured_content = structured_content;
result result
} }
@@ -447,13 +487,33 @@ async fn collect_tagged_lines(
Ok(()) Ok(())
} }
/// Build a human-readable truncation notice with platform-appropriate commands.
fn truncation_notice(info: &TruncationInfo) -> String {
let path = info.path.display();
let commands = if cfg!(windows) {
"PowerShell commands like `Get-Content -TotalCount 200`, `Select-String`, or \
`Get-Content | Select-Object -Skip 100 -First 100`"
} else {
"shell commands like `head`, `tail`, or `sed -n '100,200p'`"
};
format!(
"[{reason} Full output saved to {path}. \
Read it with {commands} up to {limit} lines at a time.]",
reason = info.reason,
limit = OUTPUT_LIMIT_LINES,
)
}
fn render_output( fn render_output(
full_output: &str, full_output: &str,
label: &str, label: &str,
output_dir: &std::path::Path, output_dir: &std::path::Path,
) -> Result<String, String> { ) -> Result<TruncateResult, String> {
if full_output.is_empty() { if full_output.is_empty() {
return Ok("(no output)".to_string()); return Ok(TruncateResult {
text: "(no output)".to_string(),
truncation: None,
});
} }
truncate_output(full_output, label, output_dir) truncate_output(full_output, label, output_dir)
} }
@@ -462,7 +522,7 @@ fn truncate_output(
full_output: &str, full_output: &str,
label: &str, label: &str,
output_dir: &std::path::Path, output_dir: &std::path::Path,
) -> Result<String, String> { ) -> Result<TruncateResult, String> {
let lines: Vec<&str> = full_output.split('\n').collect(); let lines: Vec<&str> = full_output.split('\n').collect();
let total_lines = lines.len(); let total_lines = lines.len();
let total_bytes = full_output.len(); let total_bytes = full_output.len();
@@ -471,7 +531,10 @@ fn truncate_output(
let exceeded_bytes = total_bytes > OUTPUT_LIMIT_BYTES; let exceeded_bytes = total_bytes > OUTPUT_LIMIT_BYTES;
if !exceeded_lines && !exceeded_bytes { if !exceeded_lines && !exceeded_bytes {
return Ok(full_output.to_string()); return Ok(TruncateResult {
text: full_output.to_string(),
truncation: None,
});
} }
let output_path = save_full_output(full_output, label, output_dir)?; let output_path = save_full_output(full_output, label, output_dir)?;
@@ -488,12 +551,13 @@ fn truncate_output(
) )
}; };
Ok(format!( Ok(TruncateResult {
"{preview}\n\n[{reason} Full output saved to {path}. \ text: preview,
Read it with shell commands like `head`, `tail`, or `sed -n '100,200p'` \ truncation: Some(TruncationInfo {
up to 2000 lines at a time.]", path: output_path,
path = output_path.display(), reason,
)) }),
})
} }
fn save_full_output( fn save_full_output(
@@ -584,15 +648,17 @@ mod tests {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let rendered = render_output(&input, "test", dir.path()).unwrap(); let result = render_output(&input, "test", dir.path()).unwrap();
assert_eq!(rendered, input); assert_eq!(result.text, input);
assert!(result.truncation.is_none());
} }
#[test] #[test]
fn render_output_shows_empty_message() { fn render_output_shows_empty_message() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let rendered = render_output("", "test", dir.path()).unwrap(); let result = render_output("", "test", dir.path()).unwrap();
assert_eq!(rendered, "(no output)"); assert_eq!(result.text, "(no output)");
assert!(result.truncation.is_none());
} }
#[test] #[test]
@@ -603,17 +669,22 @@ mod tests {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let rendered = render_output(&input, "test_lines", dir.path()).unwrap(); let result = render_output(&input, "test_lines", dir.path()).unwrap();
let (preview, metadata) = rendered.split_once("\n\n[").unwrap(); let preview = &result.text;
assert_eq!(preview.lines().count(), OUTPUT_PREVIEW_LINES); assert_eq!(preview.lines().count(), OUTPUT_PREVIEW_LINES);
assert!(preview.starts_with("line 2450")); assert!(preview.starts_with("line 2450"));
assert!(preview.contains("line 2499")); assert!(preview.contains("line 2499"));
assert!(metadata.contains("2000 line limit"));
assert!(metadata.contains("2500 lines total")); let info = result
assert!(metadata.contains("Full output saved to")); .truncation
assert!(metadata.contains("head")); .as_ref()
assert!(metadata.contains("sed -n")); .expect("expected truncation info");
assert!(info.reason.contains("2000 line limit"));
assert!(info.reason.contains("2500 lines total"));
let notice = truncation_notice(info);
assert!(notice.contains("Full output saved to"));
} }
#[test] #[test]
@@ -627,12 +698,16 @@ mod tests {
assert!(input.len() > OUTPUT_LIMIT_BYTES); assert!(input.len() > OUTPUT_LIMIT_BYTES);
assert!(input.lines().count() <= OUTPUT_LIMIT_LINES); assert!(input.lines().count() <= OUTPUT_LIMIT_LINES);
let rendered = render_output(&input, "test_bytes", dir.path()).unwrap(); let result = render_output(&input, "test_bytes", dir.path()).unwrap();
let (_preview, metadata) = rendered.split_once("\n\n[").unwrap(); let info = result
.truncation
.as_ref()
.expect("expected truncation info");
assert!(info.reason.contains("byte limit"));
assert!(info.reason.contains("bytes total"));
assert!(metadata.contains("byte limit")); let notice = truncation_notice(info);
assert!(metadata.contains("bytes total")); assert!(notice.contains("Full output saved to"));
assert!(metadata.contains("Full output saved to"));
} }
#[test] #[test]