fix: deliver truncation notice as separate content block (#7899)
This commit is contained in:
@@ -20,6 +20,20 @@ const OUTPUT_PREVIEW_LINES: usize = 50;
|
||||
|
||||
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)]
|
||||
pub struct ShellParams {
|
||||
pub command: String,
|
||||
@@ -165,37 +179,56 @@ impl ShellTool {
|
||||
|
||||
let output_dir = self.output_dir.path();
|
||||
let slot = self.call_index.fetch_add(1, Ordering::Relaxed) % OUTPUT_SLOTS;
|
||||
let truncated_stdout = if raw_stdout.is_empty() {
|
||||
String::new()
|
||||
let stdout_result = if raw_stdout.is_empty() {
|
||||
TruncateResult {
|
||||
text: String::new(),
|
||||
truncation: None,
|
||||
}
|
||||
} else {
|
||||
match truncate_output(&raw_stdout, &format!("stdout-{slot}"), output_dir) {
|
||||
Ok(t) => t,
|
||||
Ok(r) => r,
|
||||
Err(error) => return Self::error_result(&error, None),
|
||||
}
|
||||
};
|
||||
let truncated_stderr = if raw_stderr.is_empty() {
|
||||
String::new()
|
||||
let stderr_result = if raw_stderr.is_empty() {
|
||||
TruncateResult {
|
||||
text: String::new(),
|
||||
truncation: None,
|
||||
}
|
||||
} else {
|
||||
match truncate_output(&raw_stderr, &format!("stderr-{slot}"), output_dir) {
|
||||
Ok(t) => t,
|
||||
Ok(r) => r,
|
||||
Err(error) => return Self::error_result(&error, None),
|
||||
}
|
||||
};
|
||||
|
||||
let shell_output = ShellOutput {
|
||||
stdout: truncated_stdout,
|
||||
stderr: truncated_stderr,
|
||||
stdout: stdout_result.text,
|
||||
stderr: stderr_result.text,
|
||||
exit_code: execution.exit_code,
|
||||
timed_out: execution.timed_out,
|
||||
output_truncated: execution.output_truncated,
|
||||
output_collection_error: execution.output_collection_error.clone(),
|
||||
};
|
||||
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),
|
||||
};
|
||||
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 {
|
||||
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) {
|
||||
rendered.push_str(&format!("\n\nCommand exited with code {code}"));
|
||||
}
|
||||
let mut result =
|
||||
CallToolResult::error(vec![Content::text(rendered).with_priority(0.0)]);
|
||||
let mut error_blocks = 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;
|
||||
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
|
||||
}
|
||||
@@ -447,13 +487,33 @@ async fn collect_tagged_lines(
|
||||
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(
|
||||
full_output: &str,
|
||||
label: &str,
|
||||
output_dir: &std::path::Path,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<TruncateResult, String> {
|
||||
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)
|
||||
}
|
||||
@@ -462,7 +522,7 @@ fn truncate_output(
|
||||
full_output: &str,
|
||||
label: &str,
|
||||
output_dir: &std::path::Path,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<TruncateResult, String> {
|
||||
let lines: Vec<&str> = full_output.split('\n').collect();
|
||||
let total_lines = lines.len();
|
||||
let total_bytes = full_output.len();
|
||||
@@ -471,7 +531,10 @@ fn truncate_output(
|
||||
let exceeded_bytes = total_bytes > OUTPUT_LIMIT_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)?;
|
||||
@@ -488,12 +551,13 @@ fn truncate_output(
|
||||
)
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{preview}\n\n[{reason} Full output saved to {path}. \
|
||||
Read it with shell commands like `head`, `tail`, or `sed -n '100,200p'` \
|
||||
up to 2000 lines at a time.]",
|
||||
path = output_path.display(),
|
||||
))
|
||||
Ok(TruncateResult {
|
||||
text: preview,
|
||||
truncation: Some(TruncationInfo {
|
||||
path: output_path,
|
||||
reason,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn save_full_output(
|
||||
@@ -584,15 +648,17 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let rendered = render_output(&input, "test", dir.path()).unwrap();
|
||||
assert_eq!(rendered, input);
|
||||
let result = render_output(&input, "test", dir.path()).unwrap();
|
||||
assert_eq!(result.text, input);
|
||||
assert!(result.truncation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_output_shows_empty_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rendered = render_output("", "test", dir.path()).unwrap();
|
||||
assert_eq!(rendered, "(no output)");
|
||||
let result = render_output("", "test", dir.path()).unwrap();
|
||||
assert_eq!(result.text, "(no output)");
|
||||
assert!(result.truncation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -603,17 +669,22 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let rendered = render_output(&input, "test_lines", dir.path()).unwrap();
|
||||
let (preview, metadata) = rendered.split_once("\n\n[").unwrap();
|
||||
let result = render_output(&input, "test_lines", dir.path()).unwrap();
|
||||
let preview = &result.text;
|
||||
|
||||
assert_eq!(preview.lines().count(), OUTPUT_PREVIEW_LINES);
|
||||
assert!(preview.starts_with("line 2450"));
|
||||
assert!(preview.contains("line 2499"));
|
||||
assert!(metadata.contains("2000 line limit"));
|
||||
assert!(metadata.contains("2500 lines total"));
|
||||
assert!(metadata.contains("Full output saved to"));
|
||||
assert!(metadata.contains("head"));
|
||||
assert!(metadata.contains("sed -n"));
|
||||
|
||||
let info = result
|
||||
.truncation
|
||||
.as_ref()
|
||||
.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]
|
||||
@@ -627,12 +698,16 @@ mod tests {
|
||||
assert!(input.len() > OUTPUT_LIMIT_BYTES);
|
||||
assert!(input.lines().count() <= OUTPUT_LIMIT_LINES);
|
||||
|
||||
let rendered = render_output(&input, "test_bytes", dir.path()).unwrap();
|
||||
let (_preview, metadata) = rendered.split_once("\n\n[").unwrap();
|
||||
let result = render_output(&input, "test_bytes", dir.path()).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"));
|
||||
assert!(metadata.contains("bytes total"));
|
||||
assert!(metadata.contains("Full output saved to"));
|
||||
let notice = truncation_notice(info);
|
||||
assert!(notice.contains("Full output saved to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user