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;
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user