use crate::{
providers::ToolCall,
tools::{
ToolCapability, ToolOutputCompressionSettings, ToolResult, contract::metadata_key as meta,
},
};
use serde_json::Value;
const STATUS_LINE_CAP: usize = 12;
const DIFF_FILE_CAP: usize = 20;
const GIT_LOG_LINE_CAP: usize = 15;
const DIAGNOSTIC_LINE_CAP: usize = 30;
const FAILURE_LINE_CAP: usize = 40;
const SAFE_PIPE_COMMANDS: &[&str] = &[
"grep", "rg", "tail", "head", "sed", "cat", "wc", "sort", "uniq", "cut", "tr",
];
pub(crate) fn provider_visible_tool_output(
call: &ToolCall,
result: &ToolResult,
settings: &ToolOutputCompressionSettings,
) -> String {
if let Some(output) = failed_tool_diagnostic_output(call, result) {
return output;
}
if !settings.enabled || call.name != "bash" {
return result.content.clone();
}
let Some(command) = command_text(call) else {
return result.content.clone();
};
let Some(rule) = classify_bash_command(command) else {
return result.content.clone();
};
let stdout = metadata_string(&result.metadata, meta::STDOUT)
.map(str::to_string)
.unwrap_or_else(|| fallback_section(&result.content, "stdout:\n", "\nstderr:\n"));
let stderr = metadata_string(&result.metadata, meta::STDERR)
.map(str::to_string)
.unwrap_or_else(|| fallback_section(&result.content, "stderr:\n", ""));
let summary = match rule {
BashCompressionRule::GitStatus => compress_git_status(&stdout),
BashCompressionRule::GitDiff => compress_git_diff(&stdout),
BashCompressionRule::GitLog => compress_git_log(&stdout),
BashCompressionRule::CargoCheck => compress_cargo_check(result, &stdout, &stderr),
BashCompressionRule::CargoTest => compress_cargo_test(result, &stdout, &stderr),
};
if summary.trim().is_empty() {
return result.content.clone();
}
format!(
"[tool_output_compression]\ntool: bash\nrule: {}\ncommand: {}\nsuccess: {}\nexit_code: {}\nraw_stdout_bytes: {}\nraw_stderr_bytes: {}\nstdout_truncated: {}\nstderr_truncated: {}\ncompression: curated\n\n{}",
rule.id(),
command,
result.success,
exit_code_text(&result.metadata),
stdout.len(),
stderr.len(),
metadata_bool(&result.metadata, meta::STDOUT_TRUNCATED),
metadata_bool(&result.metadata, meta::STDERR_TRUNCATED),
summary
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BashCompressionRule {
GitStatus,
GitDiff,
GitLog,
CargoCheck,
CargoTest,
}
impl BashCompressionRule {
fn id(self) -> &'static str {
match self {
Self::GitStatus => "bash.git_status",
Self::GitDiff => "bash.git_diff",
Self::GitLog => "bash.git_log",
Self::CargoCheck => "bash.cargo_check",
Self::CargoTest => "bash.cargo_test",
}
}
}
fn command_text(call: &ToolCall) -> Option<&str> {
call.arguments
.get("command")
.or_else(|| call.arguments.get("cmd"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
}
fn classify_bash_command(command: &str) -> Option<BashCompressionRule> {
if contains_unsafe_operator(command) {
return None;
}
let segments = command.split('|').map(str::trim).collect::<Vec<_>>();
if segments.iter().any(|segment| segment.is_empty()) {
return None;
}
if segments.len() > 1
&& !segments[1..]
.iter()
.all(|segment| safe_pipe_target(segment))
{
return None;
}
let tokens = segments[0]
.split_whitespace()
.filter(|token| !is_allowed_redirect(token))
.collect::<Vec<_>>();
classify_bash_tokens(&tokens)
}
fn classify_bash_tokens(tokens: &[&str]) -> Option<BashCompressionRule> {
match tokens {
["git", "status", rest @ ..] | ["git", "--no-pager", "status", rest @ ..]
if rest.iter().all(|token| is_git_status_arg(token)) =>
{
Some(BashCompressionRule::GitStatus)
}
["git", "diff", ..] | ["git", "--no-pager", "diff", ..] => {
Some(BashCompressionRule::GitDiff)
}
["git", "log", rest @ ..] | ["git", "--no-pager", "log", rest @ ..]
if rest.contains(&"--oneline") =>
{
Some(BashCompressionRule::GitLog)
}
["cargo", "check", ..] => Some(BashCompressionRule::CargoCheck),
["cargo", "test", ..] => Some(BashCompressionRule::CargoTest),
_ => None,
}
}
fn contains_unsafe_operator(command: &str) -> bool {
if ["&&", "||", ";", "$(", "`", "\n", "\r", "<", ">>"]
.iter()
.any(|operator| command.contains(operator))
{
return true;
}
command.split_whitespace().any(|token| {
token.contains('>') && !is_allowed_redirect(token)
|| token.contains('&') && !is_allowed_redirect(token)
})
}
fn is_allowed_redirect(token: &str) -> bool {
matches!(token, "2>&1" | "1>&2")
}
fn safe_pipe_target(segment: &str) -> bool {
segment
.split_whitespace()
.next()
.is_some_and(|token| SAFE_PIPE_COMMANDS.contains(&token))
}
fn is_git_status_arg(token: &str) -> bool {
matches!(
token,
"--short" | "--porcelain" | "--porcelain=v1" | "-sb" | "--branch"
)
}
fn compress_git_status(stdout: &str) -> String {
let lines = non_empty_lines(stdout);
let branch = lines.iter().find(|line| line.starts_with("## ")).copied();
let status_lines = lines
.iter()
.copied()
.filter(|line| !line.starts_with("## "))
.collect::<Vec<_>>();
let untracked = status_lines
.iter()
.filter(|line| line.trim_start().starts_with("??"))
.count();
let mut out = vec![format!("changed_files: {}", status_lines.len())];
out.push(format!("untracked_files: {untracked}"));
if let Some(branch) = branch {
out.push(format!("branch: {branch}"));
}
out.push("status_lines:".to_string());
out.extend(capped_prefixed(&status_lines, STATUS_LINE_CAP));
out.join("\n")
}
fn compress_git_diff(stdout: &str) -> String {
let lines = stdout.lines().collect::<Vec<_>>();
let files = lines
.iter()
.filter_map(|line| line.strip_prefix("diff --git "))
.filter_map(|line| line.split_whitespace().nth(1))
.map(|path| path.strip_prefix("b/").unwrap_or(path))
.collect::<Vec<_>>();
let hunk_count = lines.iter().filter(|line| line.starts_with("@@")).count();
let insertions = lines
.iter()
.filter(|line| line.starts_with('+') && !line.starts_with("+++"))
.count();
let deletions = lines
.iter()
.filter(|line| line.starts_with('-') && !line.starts_with("---"))
.count();
let stat_lines = lines
.iter()
.copied()
.filter(|line| line.contains(" | ") || line.contains(" file changed"))
.take(DIFF_FILE_CAP)
.collect::<Vec<_>>();
let mut out = vec![
format!("files_touched: {}", files.len()),
format!("hunks: {hunk_count}"),
format!("insertions: {insertions}"),
format!("deletions: {deletions}"),
"files:".to_string(),
];
out.extend(capped_prefixed(&files, DIFF_FILE_CAP));
if !stat_lines.is_empty() {
out.push("stat_lines:".to_string());
out.extend(capped_prefixed(&stat_lines, DIFF_FILE_CAP));
}
out.join("\n")
}
fn compress_git_log(stdout: &str) -> String {
let lines = non_empty_lines(stdout);
if lines.is_empty() {
return String::new();
}
let mut out = vec![format!("commits: {}", lines.len()), "subjects:".to_string()];
out.extend(capped_prefixed(&lines, GIT_LOG_LINE_CAP));
out.join("\n")
}
fn compress_cargo_check(result: &ToolResult, stdout: &str, stderr: &str) -> String {
let combined = combined_lines(stdout, stderr);
let final_status = combined
.iter()
.rev()
.find(|line| {
line.contains("Finished ")
|| line.contains("could not compile")
|| line.contains("error:")
})
.copied();
let mut out = cargo_header(result, &combined);
if let Some(final_status) = final_status {
out.push(format!("final_status: {}", final_status.trim()));
}
out.push("diagnostics:".to_string());
out.extend(first_interesting_lines(&combined, DIAGNOSTIC_LINE_CAP));
out.join("\n")
}
fn compress_cargo_test(result: &ToolResult, stdout: &str, stderr: &str) -> String {
let combined = combined_lines(stdout, stderr);
let test_results = combined
.iter()
.copied()
.filter(|line| line.contains("test result:"))
.collect::<Vec<_>>();
let failing = combined
.iter()
.filter_map(|line| line.trim().strip_prefix("test "))
.filter_map(|line| line.strip_suffix(" ... FAILED"))
.collect::<Vec<_>>();
let mut out = cargo_header(result, &combined);
out.push("test_results:".to_string());
out.extend(capped_prefixed(&test_results, STATUS_LINE_CAP));
out.push("failing_tests:".to_string());
out.extend(capped_prefixed(&failing, STATUS_LINE_CAP));
out.push("failure_context:".to_string());
out.extend(first_interesting_lines(&combined, FAILURE_LINE_CAP));
out.join("\n")
}
fn cargo_header(result: &ToolResult, lines: &[&str]) -> Vec<String> {
let warnings = lines
.iter()
.filter(|line| line.trim_start().starts_with("warning"))
.count();
let errors = lines
.iter()
.filter(|line| line.trim_start().starts_with("error"))
.count();
let mut codes = lines
.iter()
.filter_map(|line| line.split('[').nth(1))
.filter_map(|rest| rest.split(']').next())
.filter(|code| code.starts_with('E') && code[1..].chars().all(|c| c.is_ascii_digit()))
.collect::<Vec<_>>();
codes.sort_unstable();
codes.dedup();
vec![
format!("exit_code: {}", exit_code_text(&result.metadata)),
format!("warnings: {warnings}"),
format!("errors: {errors}"),
format!("compiler_error_codes: {}", codes.join(", ")),
]
}
fn non_empty_lines(text: &str) -> Vec<&str> {
text.lines()
.filter(|line| !line.trim().is_empty())
.collect()
}
fn combined_lines<'a>(stdout: &'a str, stderr: &'a str) -> Vec<&'a str> {
stdout
.lines()
.chain(stderr.lines())
.filter(|line| !line.trim().is_empty())
.collect()
}
fn capped_prefixed<T: AsRef<str>>(lines: &[T], cap: usize) -> Vec<String> {
lines
.iter()
.take(cap)
.map(|line| format!("- {}", line.as_ref()))
.collect()
}
fn first_interesting_lines(lines: &[&str], cap: usize) -> Vec<String> {
lines
.iter()
.copied()
.filter(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("error")
|| trimmed.starts_with("warning")
|| trimmed.starts_with("note")
|| trimmed.starts_with("help")
|| trimmed.starts_with("thread '")
|| trimmed.starts_with("failures:")
|| trimmed.starts_with("---- ")
|| trimmed.contains("panicked at")
|| trimmed.contains("FAILED")
|| trimmed.contains("could not compile")
})
.take(cap)
.map(|line| format!("- {}", line.trim_end()))
.collect()
}
fn failed_tool_diagnostic_output(call: &ToolCall, result: &ToolResult) -> Option<String> {
if result.success
|| !result.content.trim().is_empty()
|| ToolCapability::from_dispatch_name(&call.name) != Some(ToolCapability::Grep)
{
return None;
}
metadata_string(&result.metadata, meta::STDERR)
.filter(|stderr| !stderr.trim().is_empty())
.or_else(|| {
metadata_string(&result.metadata, meta::STDOUT)
.filter(|stdout| !stdout.trim().is_empty())
})
.map(str::to_string)
}
fn metadata_string<'a>(metadata: &'a Value, key: &str) -> Option<&'a str> {
metadata.get(key).and_then(Value::as_str)
}
fn metadata_bool(metadata: &Value, key: &str) -> bool {
metadata.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn exit_code_text(metadata: &Value) -> String {
metadata
.get(meta::EXIT_CODE)
.and_then(Value::as_i64)
.map(|code| code.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn fallback_section(content: &str, prefix: &str, suffix: &str) -> String {
let Some(start) = content.find(prefix).map(|index| index + prefix.len()) else {
return String::new();
};
if suffix.is_empty() {
return content[start..].to_string();
}
content[start..]
.find(suffix)
.map(|end| content[start..start + end].to_string())
.unwrap_or_else(|| content[start..].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{ToolResultDisplay, ToolSettings};
use serde_json::json;
fn bash_call(command: &str) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: "bash".to_string(),
arguments: json!({"command": command}),
}
}
fn grep_call(name: &str, pattern: &str) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments: json!({"pattern": pattern, "path": "."}),
}
}
fn bash_result(
stdout: &str,
stderr: &str,
success: bool,
exit_code: Option<i32>,
) -> ToolResult {
ToolResult {
tool_name: "bash".to_string(),
success,
content: format!("stdout:\n{stdout}\nstderr:\n{stderr}"),
metadata: json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"stdout_truncated": false,
"stderr_truncated": false
}),
display: ToolResultDisplay::default(),
}
}
fn enabled() -> ToolOutputCompressionSettings {
ToolOutputCompressionSettings { enabled: true }
}
#[test]
fn disabled_passthrough_returns_exact_content() {
let result = bash_result("M README.md\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("git status --short"),
&result,
&ToolSettings::default().output_compression,
);
assert_eq!(output, result.content);
}
#[test]
fn compressed_provider_output_does_not_change_local_structured_detail() {
let call = bash_call("git status --short");
let result = bash_result("M src/lib.rs\n?? new.txt\n", "", true, Some(0));
let provider_output = provider_visible_tool_output(&call, &result, &enabled());
let detail = crate::tool_display::tool_activity_detail(
&call,
&result,
"bash git status --short",
crate::output::ActivityStatus::Success,
);
assert!(provider_output.starts_with("[tool_output_compression]"));
assert_ne!(provider_output, result.content);
assert_eq!(detail.output.as_ref(), result.content);
assert!(!detail.output.contains("[tool_output_compression]"));
}
#[test]
fn invalid_grep_regex_provider_output_includes_diagnostic() {
let result = ToolResult {
tool_name: "grep".to_string(),
success: false,
content: String::new(),
metadata: json!({
"stdout": "",
"stderr": "regex parse error:\n [unterminated character class\n",
}),
display: ToolResultDisplay::default(),
};
let output = provider_visible_tool_output(&grep_call("grep", "["), &result, &enabled());
assert!(output.contains("regex parse error"));
assert_eq!(result.content, "");
}
#[test]
fn invalid_legacy_grep_regex_provider_output_includes_diagnostic() {
let result = ToolResult {
tool_name: "ffgrep".to_string(),
success: false,
content: String::new(),
metadata: json!({
"stdout": "",
"stderr": "regex parse error:\n [unterminated character class\n",
}),
display: ToolResultDisplay::default(),
};
let output = provider_visible_tool_output(&grep_call("ffgrep", "["), &result, &enabled());
assert!(output.contains("regex parse error"));
assert_eq!(result.content, "");
}
#[test]
fn non_bash_and_unrecognized_passthrough() {
let mut call = bash_call("echo hi");
let result = bash_result("hi\n", "", true, Some(0));
assert_eq!(
provider_visible_tool_output(&call, &result, &enabled()),
result.content
);
call.name = "read".to_string();
assert_eq!(
provider_visible_tool_output(&call, &result, &enabled()),
result.content
);
}
#[test]
fn git_status_short_summary_counts_files() {
let result = bash_result(
"## main...origin/main [ahead 1]\n M src/lib.rs\n?? notes.md\n",
"",
true,
Some(0),
);
let output = provider_visible_tool_output(
&bash_call("git status --short --branch"),
&result,
&enabled(),
);
assert!(output.contains("rule: bash.git_status"));
assert!(output.contains("changed_files: 2"));
assert!(output.contains("untracked_files: 1"));
assert!(output.contains("branch: ## main...origin/main [ahead 1]"));
}
#[test]
fn git_diff_summary_counts_files_hunks_and_lines() {
let stdout = "diff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1,2 @@\n-old\n+new\n+more\n";
let output = provider_visible_tool_output(
&bash_call("git diff -- src/lib.rs"),
&bash_result(stdout, "", true, Some(0)),
&enabled(),
);
assert!(output.contains("rule: bash.git_diff"));
assert!(output.contains("files_touched: 1"));
assert!(output.contains("hunks: 1"));
assert!(output.contains("insertions: 2"));
assert!(output.contains("deletions: 1"));
}
#[test]
fn cargo_check_summary_includes_diagnostics() {
let stderr = "error[E0425]: cannot find value `x` in this scope\nwarning: unused import: `Foo`\nerror: could not compile `demo`\n";
let output = provider_visible_tool_output(
&bash_call("cargo check --all-targets"),
&bash_result("", stderr, false, Some(101)),
&enabled(),
);
assert!(output.contains("rule: bash.cargo_check"));
assert!(output.contains("exit_code: 101"));
assert!(output.contains("warnings: 1"));
assert!(output.contains("errors: 2"));
assert!(output.contains("compiler_error_codes: E0425"));
}
#[test]
fn cargo_test_summary_includes_failures() {
let stdout = "test tests::fails ... FAILED\ntest result: FAILED. 0 passed; 1 failed\nfailures:\n---- tests::fails stdout ----\nthread 'tests::fails' panicked at src/lib.rs:1:1\n";
let output = provider_visible_tool_output(
&bash_call("cargo test tool_output_compression"),
&bash_result(stdout, "", false, Some(101)),
&enabled(),
);
assert!(output.contains("rule: bash.cargo_test"));
assert!(output.contains("- tests::fails"));
assert!(output.contains("test result: FAILED"));
assert!(output.contains("panicked at"));
}
fn assert_rule(command: &str, stdout: &str, stderr: &str, rule: &str) -> String {
let output = provider_visible_tool_output(
&bash_call(command),
&bash_result(stdout, stderr, true, Some(0)),
&enabled(),
);
assert!(output.contains(&format!("rule: {rule}")), "{output}");
assert!(output.contains(&format!("command: {command}")), "{output}");
output
}
fn assert_passthrough(command: &str) {
let result = bash_result("raw output\n", "", true, Some(0));
let output = provider_visible_tool_output(&bash_call(command), &result, &enabled());
assert_eq!(output, result.content, "{command}");
}
#[test]
fn git_no_pager_status_and_diff_classify_like_base_commands() {
assert_rule(
"git --no-pager diff",
"diff --git a/a b/a\n@@ -1 +1 @@\n-a\n+b\n",
"",
"bash.git_diff",
);
assert_rule(
"git --no-pager status --short",
" M src/lib.rs\n",
"",
"bash.git_status",
);
assert_passthrough("git --no-pager status --bad");
}
#[test]
fn git_log_oneline_summary_counts_and_caps_subjects() {
let stdout = (1..=20)
.map(|index| format!("abc{index:02} subject {index}"))
.collect::<Vec<_>>()
.join("\n");
let output = assert_rule("git log --oneline -20", &stdout, "", "bash.git_log");
assert!(output.contains("commits: 20"));
assert!(output.contains("subjects:"));
assert!(output.contains("- abc01 subject 1"));
assert!(output.contains("- abc15 subject 15"));
assert!(!output.contains("- abc16 subject 16"));
assert_passthrough("git log --pretty=oneline");
assert_passthrough("git log -5");
assert_rule(
"git --no-pager log --oneline -20",
"abc123 subject\n",
"",
"bash.git_log",
);
}
#[test]
fn safe_fd_redirects_are_stripped_before_classification() {
assert_rule(
"cargo test --lib 2>&1",
"test result: ok. 1 passed\n",
"",
"bash.cargo_test",
);
assert_rule(
"cargo check 1>&2",
"",
"Finished `dev` profile\n",
"bash.cargo_check",
);
assert_passthrough("cargo test < input.txt");
assert_passthrough("cargo test > out.log");
assert_passthrough("cargo test 2>err.log");
assert_passthrough("cargo test >>out.log");
assert_passthrough("cargo test 2>&1foo");
assert_passthrough("cargo test 2 > & 1");
}
#[test]
fn safe_pipe_whitelist_classifies_base_command_only() {
assert_rule(
"cargo test --lib 2>&1 | tail -25",
"test result: ok. 1 passed\n",
"",
"bash.cargo_test",
);
assert_rule(
"cargo test 2>&1 | grep -E \"FAILED\" | head -10",
"test tests::fails ... FAILED\n",
"",
"bash.cargo_test",
);
assert_rule(
"git diff | cat",
"diff --git a/a b/a\n@@ -1 +1 @@\n-a\n+b\n",
"",
"bash.git_diff",
);
assert_passthrough("git diff | xargs rm");
assert_passthrough("git diff | awk '{print $1}'");
assert_passthrough("git diff |");
assert_passthrough("git diff || true");
assert_passthrough("git diff | tee diff.txt");
}
#[test]
fn disabled_passthrough_for_new_pipe_shape_returns_exact_content() {
let result = bash_result("test result: ok. 1 passed\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("cargo test --lib 2>&1 | tail -25"),
&result,
&ToolSettings::default().output_compression,
);
assert_eq!(output, result.content);
}
#[test]
fn compound_shell_command_rejected() {
let result = bash_result("M README.md\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("git status --short && git diff"),
&result,
&enabled(),
);
assert_eq!(output, result.content);
}
#[test]
fn newline_compound_command_rejected() {
let result = bash_result("test output\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("cargo test\ncat target.log"),
&result,
&enabled(),
);
assert_eq!(output, result.content);
}
#[test]
fn carriage_return_compound_command_rejected() {
let result = bash_result("check output\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("cargo check\rcat target.log"),
&result,
&enabled(),
);
assert_eq!(output, result.content);
}
#[test]
fn single_ampersand_compound_command_rejected() {
let result = bash_result("diff output\n", "", true, Some(0));
let output = provider_visible_tool_output(
&bash_call("git diff & cat target.log"),
&result,
&enabled(),
);
assert_eq!(output, result.content);
}
}