fix(buzz-agent): cap tool-result text at 50 KiB with middle elision (#952)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-12 10:39:34 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent 2846a96ed2
commit 84f499cb6e
5 changed files with 192 additions and 78 deletions
+3 -1
View File
@@ -153,6 +153,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. |
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
## Providers
@@ -234,7 +235,8 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP
| History window | 1 MiB | `BUZZ_AGENT_MAX_HISTORY_BYTES` |
| LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` |
| LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` |
| Tool result body | 256 KiB | `MAX_TOOL_RESULT_BYTES` |
| Tool result body (total, incl. images) | 8 MiB | `MAX_TOOL_RESULT_BYTES` |
| Tool result text | 50 KiB | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` |
| MCP servers / session | 16 | `MAX_MCP_SERVERS` |
| Tools / session | 128 | `MAX_TOOLS_PER_SESSION` |
| Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` |
+8 -2
View File
@@ -8,6 +8,7 @@ use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_
use crate::handoff::HandoffOutcome;
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::mcp::ResultBudget;
use crate::types::{
AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
@@ -304,6 +305,10 @@ impl RunCtx<'_> {
let wire = self.wire.clone();
let session_id = self.session_id.to_owned();
let timeout = self.cfg.tool_timeout;
let budget = ResultBudget {
total: MAX_TOOL_RESULT_BYTES,
text: self.cfg.max_tool_result_text_bytes,
};
let cancel = self.cancel.clone();
let sem = Arc::clone(&sem);
set.spawn(async move {
@@ -317,7 +322,7 @@ impl RunCtx<'_> {
}
};
emit_in_progress(&wire, &session_id, &call).await;
let outcome = invoke_tool_inner(&mcp, &call, timeout, cancel).await;
let outcome = invoke_tool_inner(&mcp, &call, timeout, budget, cancel).await;
match &outcome {
InvokeOutcome::Done(result) => {
emit_completed(&wire, &session_id, &call, result).await;
@@ -419,6 +424,7 @@ async fn invoke_tool_inner(
mcp: &Arc<McpRegistry>,
call: &ToolCall,
tool_timeout: std::time::Duration,
budget: ResultBudget,
mut cancel: watch::Receiver<bool>,
) -> InvokeOutcome {
if *cancel.borrow() {
@@ -430,7 +436,7 @@ async fn invoke_tool_inner(
&call.name,
&call.provider_id,
&call.arguments,
MAX_TOOL_RESULT_BYTES,
budget,
&mut cancel,
),
)
+26
View File
@@ -3,7 +3,16 @@ use std::time::Duration;
pub const PROTOCOL_VERSION: u32 = 1;
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
/// Total per-result byte ceiling (text + images). Sized for image-bearing
/// results — view_image can legitimately return multi-MiB base64 payloads.
/// Text is governed by the much smaller `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub const MAX_TOOL_RESULT_BYTES: usize = 8 * 1024 * 1024;
/// Default cap on the *text* portion of a single tool result. Oversized text
/// is middle-elided before it enters history; without this, one fat `cat`
/// burns the context window and forces a lossy handoff. 50 KiB matches the
/// shell-output caps in sprout-dev-mcp, goose, and pi; codex defaults to
/// 10 KB. Tunable via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub const DEFAULT_TOOL_RESULT_TEXT_BYTES: usize = 50 * 1024;
pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
@@ -56,6 +65,11 @@ pub struct Config {
pub max_sessions: usize,
pub max_line_bytes: usize,
pub max_history_bytes: usize,
/// Per-tool-result cap on text content. Oversized text is middle-elided
/// (head + tail kept) before entering history. Images are exempt — they
/// are bounded by [`MAX_TOOL_RESULT_BYTES`] and accounted separately.
/// Set via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub max_tool_result_text_bytes: usize,
/// Provider context window in tokens used to gate handoff. The handoff
/// fires when the previous request's (cache-summed) input tokens cross the
/// handoff threshold for this budget, before the next request can exceed
@@ -163,6 +177,10 @@ impl Config {
max_sessions: parse_env("BUZZ_AGENT_MAX_SESSIONS", usize::MAX)?,
max_line_bytes: parse_env("BUZZ_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?,
max_history_bytes: parse_env("BUZZ_AGENT_MAX_HISTORY_BYTES", 16 * 1024 * 1024)?,
max_tool_result_text_bytes: parse_env(
"BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES",
DEFAULT_TOOL_RESULT_TEXT_BYTES,
)?,
max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?,
max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?,
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
@@ -178,6 +196,7 @@ impl Config {
fn validate(&self) -> Result<(), String> {
const MIN_HISTORY_BYTES: usize = 4096;
const MIN_LINE_BYTES: usize = 1024;
const MIN_TOOL_RESULT_TEXT_BYTES: usize = 1024;
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
if self.max_output_tokens < 1 {
@@ -205,6 +224,13 @@ impl Config {
"config: BUZZ_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}"
));
}
if self.max_tool_result_text_bytes < MIN_TOOL_RESULT_TEXT_BYTES
|| self.max_tool_result_text_bytes > MAX_TOOL_RESULT_BYTES
{
return Err(format!(
"config: BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES must be in {MIN_TOOL_RESULT_TEXT_BYTES}..={MAX_TOOL_RESULT_BYTES}"
));
}
if self.llm_timeout < MIN_TIMEOUT {
return Err("config: BUZZ_AGENT_LLM_TIMEOUT_SECS must be >= 1".into());
}
+1
View File
@@ -924,6 +924,7 @@ mod tests {
max_sessions: 1,
max_line_bytes: 1024 * 1024,
max_history_bytes: 16 * 1024 * 1024,
max_tool_result_text_bytes: 50 * 1024,
max_context_tokens: 200_000,
max_handoffs: 1,
max_parallel_tools: 1,
+154 -75
View File
@@ -26,6 +26,16 @@ const MARKER_FIELD_MAX: usize = 256;
pub const MAX_MCP_SERVERS: usize = 16;
const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024;
/// Byte budgets for a single tool result. `total` bounds everything the
/// result may occupy in history (text + images); `text` bounds the text
/// portion alone, since text is where runaway outputs (build logs, file
/// dumps) live while images are legitimately large and self-limiting.
#[derive(Clone, Copy)]
pub struct ResultBudget {
pub total: usize,
pub text: usize,
}
const PASSTHROUGH_ENV: &[&str] = &[
// Core
"PATH",
@@ -322,7 +332,10 @@ impl McpRegistry {
&qname,
"hook",
&args,
MAX_HOOK_RESULT_BYTES,
ResultBudget {
total: MAX_HOOK_RESULT_BYTES,
text: MAX_HOOK_RESULT_BYTES,
},
&mut dummy_cancel,
),
)
@@ -454,7 +467,7 @@ impl McpRegistry {
qname: &str,
provider_id: &str,
arguments: &Value,
max_bytes: usize,
budget: ResultBudget,
cancel: &mut watch::Receiver<bool>,
) -> Result<ToolResult, AgentError> {
let entry = self
@@ -480,7 +493,7 @@ impl McpRegistry {
qname,
provider_id,
arguments,
max_bytes,
budget,
cancel,
)
.await;
@@ -513,7 +526,7 @@ impl McpRegistry {
qname,
provider_id,
arguments,
max_bytes,
budget,
cancel,
)
.await
@@ -528,7 +541,7 @@ impl McpRegistry {
qname: &str,
provider_id: &str,
arguments: &Value,
max_bytes: usize,
budget: ResultBudget,
cancel: &mut watch::Receiver<bool>,
) -> Result<ToolResult, AgentError> {
let arg_obj = match arguments {
@@ -596,13 +609,13 @@ impl McpRegistry {
provider_id: provider_id.to_owned(),
content: vec![ToolResultContent::Text(clamp(
format!("Tool call rejected: {e}"),
max_bytes,
budget.text,
))],
is_error: true,
});
}
};
let content = tool_result_content(&res.content, max_bytes);
let content = tool_result_content(&res.content, budget.total, budget.text);
Ok(ToolResult {
provider_id: provider_id.to_owned(),
content,
@@ -833,54 +846,85 @@ pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str {
&s[..cut]
}
fn push_bounded(out: &mut String, s: &str, max: usize) {
let remaining = max.saturating_sub(out.len());
if remaining > 0 {
out.push_str(truncate_at_boundary(s, remaining));
/// Byte allowance reserved for the elision marker inside [`truncate_middle`].
/// The marker is ~80 bytes; the slack keeps the arithmetic safely one-sided.
const ELISION_MARKER_ALLOWANCE: usize = 128;
/// Truncate `s` to at most `max` bytes by eliding the *middle*, keeping the
/// head and tail. Tool output puts its conclusion at the end (test summaries,
/// error trailers) and its identity at the start; head-only truncation loses
/// the part the model needs most. The marker reports how much was elided so
/// the model knows to re-run a narrower command rather than trust the gap.
pub(crate) fn truncate_middle(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_owned();
}
let keep = max.saturating_sub(ELISION_MARKER_ALLOWANCE);
if keep == 0 {
// Budget too small for head + marker + tail; degrade to a head cut.
return truncate_at_boundary(s, max).to_owned();
}
let head = truncate_at_boundary(s, keep.div_ceil(2));
let mut tail_start = s.len() - keep / 2;
while tail_start < s.len() && !s.is_char_boundary(tail_start) {
tail_start += 1;
}
let tail = &s[tail_start..];
let elided = s.len() - head.len() - tail.len();
format!(
"{head}\n[... {elided} of {} bytes elided from tool result ...]\n{tail}",
s.len()
)
}
/// Assemble tool-result content under two budgets: `max_bytes` bounds the
/// whole result (text + images), `max_text_bytes` bounds the text portion
/// alone. Images are large by nature and pass through whole or get elided
/// with a marker; text is middle-elided so the head (what ran) and tail
/// (how it ended) both survive. Every elision leaves an inline marker.
fn tool_result_content(
blocks: &[rmcp::model::Content],
max_bytes: usize,
max_text_bytes: usize,
) -> Vec<ToolResultContent> {
use rmcp::model::RawContent;
let mut out = Vec::new();
let mut text = String::new();
let mut used = 0usize;
let mut truncated = false;
let mut used = 0usize; // total bytes emitted (text + images)
let mut text_used = 0usize; // text bytes emitted
let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned();
let flush_text = |out: &mut Vec<ToolResultContent>, text: &mut String, used: &mut usize| {
if !text.is_empty() {
*used = used.saturating_add(text.len());
out.push(ToolResultContent::Text(std::mem::take(text)));
// Flush accumulated text, middle-eliding to whatever budget remains.
let flush_text = |out: &mut Vec<ToolResultContent>,
text: &mut String,
used: &mut usize,
text_used: &mut usize| {
if text.is_empty() {
return;
}
let budget = max_text_bytes
.saturating_sub(*text_used)
.min(max_bytes.saturating_sub(*used));
let kept = truncate_middle(&std::mem::take(text), budget);
*used = used.saturating_add(kept.len());
*text_used = text_used.saturating_add(kept.len());
if !kept.is_empty() {
out.push(ToolResultContent::Text(kept));
}
};
let text_budget =
|used: usize, text: &str| max_bytes.saturating_sub(used).saturating_sub(text.len());
let append = |text: &mut String, s: &str| {
if !text.is_empty() {
text.push('\n');
}
text.push_str(s);
};
for c in blocks {
if used + text.len() >= max_bytes {
truncated = true;
break;
}
match &c.raw {
RawContent::Text(t) => {
if !text.is_empty() {
let max = text_budget(used, &text);
push_bounded(&mut text, "\n", max);
}
let before = text.len();
let max = text_budget(used, &text);
push_bounded(&mut text, &t.text, max);
if text.len() - before < t.text.len() {
truncated = true;
}
}
RawContent::Text(t) => append(&mut text, &t.text),
RawContent::Image(i) => {
flush_text(&mut out, &mut text, &mut used);
flush_text(&mut out, &mut text, &mut used, &mut text_used);
let image_bytes = i.data.len().saturating_add(i.mime_type.len());
if used.saturating_add(image_bytes) <= max_bytes {
used = used.saturating_add(image_bytes);
@@ -889,53 +933,31 @@ fn tool_result_content(
mime_type: i.mime_type.clone(),
});
} else {
truncated = true;
let marker = format!(
"[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]",
short(&i.mime_type),
i.data.len()
append(
&mut text,
&format!(
"[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]",
short(&i.mime_type),
i.data.len()
),
);
let max = max_bytes.saturating_sub(used);
push_bounded(&mut text, &marker, max);
}
}
RawContent::Audio(a) => {
if !text.is_empty() {
let max = text_budget(used, &text);
push_bounded(&mut text, "\n", max);
}
let chunk = format!(
RawContent::Audio(a) => append(
&mut text,
&format!(
"[audio elided: {}, {} bytes]",
short(&a.mime_type),
a.data.len()
);
let max = text_budget(used, &text);
push_bounded(&mut text, &chunk, max);
}
),
),
RawContent::ResourceLink(r) => {
if !text.is_empty() {
let max = text_budget(used, &text);
push_bounded(&mut text, "\n", max);
}
let chunk = format!("[resource: {}]", short(&r.uri));
let max = text_budget(used, &text);
push_bounded(&mut text, &chunk, max);
}
RawContent::Resource(_) => {
if !text.is_empty() {
let max = text_budget(used, &text);
push_bounded(&mut text, "\n", max);
}
let max = text_budget(used, &text);
push_bounded(&mut text, "[resource elided]", max);
append(&mut text, &format!("[resource: {}]", short(&r.uri)));
}
RawContent::Resource(_) => append(&mut text, "[resource elided]"),
}
}
if truncated {
let max = text_budget(used, &text);
push_bounded(&mut text, "\n[content truncated]", max);
}
flush_text(&mut out, &mut text, &mut used);
flush_text(&mut out, &mut text, &mut used, &mut text_used);
out
}
@@ -951,7 +973,7 @@ mod content_tests {
Content::image("aW1n", "image/png"),
Content::text("tail"),
];
let out = tool_result_content(&blocks, 1024);
let out = tool_result_content(&blocks, 1024, 1024);
assert_eq!(out.len(), 3);
assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "header"));
assert!(matches!(
@@ -965,8 +987,65 @@ mod content_tests {
#[test]
fn tool_result_content_elides_images_over_budget() {
let blocks = vec![Content::image("a".repeat(300), "image/png")];
let out = tool_result_content(&blocks, 256);
let out = tool_result_content(&blocks, 256, 256);
assert_eq!(out.len(), 1);
assert!(matches!(&out[0], ToolResultContent::Text(t) if t.contains("image elided")));
}
#[test]
fn oversized_text_is_middle_elided() {
let mut body = String::new();
for i in 0..5000 {
body.push_str(&format!("line {i}\n"));
}
let blocks = vec![Content::text(body.clone())];
let out = tool_result_content(&blocks, 1024 * 1024, 4096);
assert_eq!(out.len(), 1);
let ToolResultContent::Text(t) = &out[0] else {
panic!("expected text");
};
assert!(t.len() <= 4096, "text exceeds budget: {}", t.len());
assert!(t.starts_with("line 0\n"), "head lost");
assert!(t.ends_with("line 4999\n"), "tail lost");
assert!(
t.contains("bytes elided from tool result"),
"missing elision marker"
);
}
#[test]
fn text_within_budget_is_untouched() {
let blocks = vec![Content::text("short output")];
let out = tool_result_content(&blocks, 1024 * 1024, 4096);
assert_eq!(out.len(), 1);
assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "short output"));
}
#[test]
fn image_passes_whole_even_when_text_budget_is_small() {
let big_text = "x".repeat(10_000);
let img = "a".repeat(100_000);
let blocks = vec![
Content::text(big_text),
Content::image(img.clone(), "image/png"),
];
let out = tool_result_content(&blocks, 8 * 1024 * 1024, 4096);
assert_eq!(out.len(), 2);
assert!(matches!(&out[0], ToolResultContent::Text(t) if t.len() <= 4096));
assert!(matches!(
&out[1],
ToolResultContent::Image { data, .. } if data == &img
));
}
#[test]
fn truncate_middle_respects_max_and_boundaries() {
let s = "é".repeat(60_000); // 2-byte chars stress boundary handling
for max in [200usize, 1024, 50 * 1024] {
let out = super::truncate_middle(&s, max);
assert!(out.len() <= max, "max={max} got {}", out.len());
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
}
assert_eq!(super::truncate_middle("ok", 1024), "ok");
}
}