From 0b9a738de1ff847ceed57e093e2577742d96f62b Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Tue, 20 Oct 2026 17:06:43 +0200 Subject: [PATCH 1/3] fix(context): one line budget per tool result, shared by its text blocks `tool_output_max_lines` was applied to each text block on its own, so since #347 (every block sent) an N-block result reached the model as up to N x the budget. Blocks now share it: short blocks stay whole, long ones split the rest, each cut with its own marker and stash key; a cut block keeps at least 5 lines so it is never cut without a marker. Idempotent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 4 + docs/concepts/context-management.md | 2 +- src/context.rs | 191 +++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 240b9572..c18b3b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +- **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 (which sends every block, #347) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks shorter than an even share stay whole, the long ones split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. A cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much, never cut without a marker. One-block results are unchanged; truncation stays idempotent, so already truncated history is not rewritten. + ## yoagent-rutis Unreleased yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.0. Additive. diff --git a/docs/concepts/context-management.md b/docs/concepts/context-management.md index 349e371f..a60cb6f8 100644 --- a/docs/concepts/context-management.md +++ b/docs/concepts/context-management.md @@ -148,7 +148,7 @@ let config = ContextConfig::from_context_window(1_000_000); ### Level 1: Truncate Tool Outputs -Replaces long tool outputs with head + tail, fitting the result into exactly `tool_output_max_lines` lines (the `[... N lines truncated ...]` marker is charged against that budget). This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. +Replaces long tool outputs with head + tail, fitting the result into exactly `tool_output_max_lines` lines (the `[... N lines truncated ...]` marker is charged against that budget). The budget is the whole result's: when a result has several text blocks (an MCP server's, say), blocks shorter than an even share stay whole and the long ones split the rest, each cut with its own marker; a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much rather than being cut silently. This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. Truncation is idempotent: re-running it on an already-truncated output returns it byte for byte, so a session that sits above the budget does not re-truncate the same outputs turn after turn. diff --git a/src/context.rs b/src/context.rs index 44fbd785..d79d713a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -610,16 +610,16 @@ pub fn truncate_tool_output_keyed( is_error, timestamp, }) => { - let max_lines = config.max_lines_for(&tool_name); + let budgets = block_budgets(&content, config.max_lines_for(&tool_name)); let mut marked = Vec::new(); let truncated_content: Vec = content .into_iter() + .zip(budgets) .enumerate() - .map(|(i, c)| match c { + .map(|(i, (c, budget))| match c { Content::Text { text } => { let key = base_key.map(|b| block_key(b, i)); - let (out, emitted) = - truncate_text_head_tail(&text, max_lines, key.as_deref()); + let (out, emitted) = truncate_text_head_tail(&text, budget, key.as_deref()); if emitted { marked.push(i); } @@ -647,6 +647,10 @@ pub fn truncate_tool_output_keyed( /// Cap a single tool result's text at the tool's line budget, keeping head and /// tail. /// +/// The budget is the result's, shared by its text blocks: blocks shorter than an +/// even share stay whole, the rest split what is left and are cut head + tail, +/// each with its own marker, never below 5 lines (head, marker, tail). +/// /// The budget comes from [`ContextConfig::max_lines_for`], so a tool that /// tolerates head+tail badly can be exempted. Non-tool-result messages pass /// through untouched, and the operation is idempotent, so applying it when the @@ -660,6 +664,71 @@ pub fn truncate_tool_output(msg: AgentMessage, config: &ContextConfig) -> AgentM /// Lines the truncation marker occupies: a blank line, the marker, a blank line. const TRUNCATION_MARKER_LINES: usize = 3; +/// The fewest lines a cut block keeps: one head line, the marker, one tail +/// line — so every cut block says it was cut. +const MIN_BLOCK_LINES: usize = TRUNCATION_MARKER_LINES + 2; + +/// Each content block's line budget, from one budget for the whole result. +/// +/// The text blocks share `max_lines`: when they fit together, every block is +/// left alone; otherwise blocks shorter than an even share stay whole and the +/// rest split what is left evenly (the remainder to the earliest), so a +/// many-block result (an MCP server's) fits the budget one block would. A +/// block is never cut below [`MIN_BLOCK_LINES`] (or `max_lines`, if smaller), +/// so a result with more long blocks than the budget can mark goes over it by +/// at most that many lines per block — never silently. +/// +/// Idempotent with [`truncate_text_head_tail`]: a cut block is exactly its +/// budget long, so the result either fits afterwards (and a second pass +/// leaves it alone) or every cut block is at the floor, which a second pass +/// assigns again. +fn block_budgets(content: &[Content], max_lines: usize) -> Vec { + let lines: Vec> = content + .iter() + .map(|c| match c { + Content::Text { text } => Some(text.lines().count()), + _ => None, + }) + .collect(); + let mut budgets: Vec = vec![usize::MAX; content.len()]; + let total = lines + .iter() + .flatten() + .fold(0usize, |a, n| a.saturating_add(*n)); + if total <= max_lines { + return budgets; + } + // Shortest first: a block that fits an even share of what is left keeps + // all its lines and leaves the rest to the others. + let mut order: Vec<(usize, usize)> = lines + .iter() + .enumerate() + .filter_map(|(i, n)| n.map(|n| (n, i))) + .collect(); + order.sort_unstable(); + let mut left = max_lines; + let mut rest = order.len(); + let mut cut = Vec::new(); + for (n, i) in order { + if cut.is_empty() && n <= left / rest { + budgets[i] = n; + left -= n; + rest -= 1; + } else { + cut.push(i); + } + } + // The cut blocks, in content order, share what is left. + cut.sort_unstable(); + let share = left / cut.len(); + let extra = left % cut.len(); + let floor = MIN_BLOCK_LINES.min(max_lines); + for (k, i) in cut.into_iter().enumerate() { + budgets[i] = (share + usize::from(k < extra)).max(floor); + } + budgets +} + /// Truncate text keeping the head and tail, dropping the middle. /// /// The marker is charged against `max_lines`, so the result is exactly @@ -1661,6 +1730,120 @@ mod tests { assert!(!marker.chars().any(|c| c.is_ascii_digit())); } + fn lines_block(prefix: &str, n: usize) -> Content { + Content::Text { + text: (0..n) + .map(|i| format!("{prefix}{i}")) + .collect::>() + .join("\n"), + } + } + + fn result_with(content: Vec) -> AgentMessage { + AgentMessage::Llm(Message::ToolResult { + tool_call_id: "tc-1".into(), + tool_name: "mcp_tool".into(), + content, + is_error: false, + timestamp: 0, + }) + } + + fn content_of(msg: &AgentMessage) -> &[Content] { + match msg { + AgentMessage::Llm(Message::ToolResult { content, .. }) => content, + _ => panic!("expected a tool result"), + } + } + + fn block_lines(msg: &AgentMessage) -> Vec { + block_texts(msg) + .into_iter() + .map(|(_, t)| t.lines().count()) + .collect() + } + + #[test] + fn the_line_budget_is_the_result_s_not_each_block_s() { + let config = ContextConfig { + tool_output_max_lines: 200, + ..Default::default() + }; + // Twenty blocks of 200 lines: before, each kept 200 (4,000 in all). + let many = result_with( + (0..20) + .map(|b| lines_block(&format!("b{b}-"), 200)) + .collect(), + ); + let (once, marked) = truncate_tool_output_keyed(many, &config, None); + assert_eq!(block_lines(&once).iter().sum::(), 200); + assert_eq!( + marked, + (0..20).collect::>(), + "every cut block says so" + ); + assert_eq!( + truncate_tool_output(once.clone(), &config), + once, + "idempotent" + ); + + // Blocks that fit together are left alone, however many. + let fits = result_with((0..4).map(|b| lines_block(&format!("b{b}-"), 50)).collect()); + assert_eq!(truncate_tool_output(fits.clone(), &config), fits); + + // A short block stays whole; the long ones share what is left, the + // remainder to the earliest; an image keeps its place and its index. + let mixed = result_with(vec![ + lines_block("a", 300), + Content::Image { + data: "x".into(), + mime_type: "image/png".into(), + }, + lines_block("s", 10), + lines_block("c", 300), + ]); + let (once, marked) = truncate_tool_output_keyed(mixed, &config, Some("k")); + assert_eq!(block_lines(&once), [95, 10, 95]); + assert_eq!(marked, [0, 3]); + let texts = block_texts(&once); + assert_eq!(texts[1].1.lines().count(), 10); + assert!( + texts[2].1.contains("shared_state get \"k-b3\""), + "{}", + texts[2].1 + ); + assert!(matches!(&content_of(&once)[1], Content::Image { .. })); + assert_eq!( + truncate_tool_output(once.clone(), &config), + once, + "idempotent" + ); + } + + #[test] + fn more_long_blocks_than_the_budget_can_mark_keep_a_marker_each() { + let config = ContextConfig { + tool_output_max_lines: 20, + ..Default::default() + }; + // Ten long blocks and room for two lines each: every one keeps the + // fewest lines a marker needs rather than being cut silently. + let msg = result_with( + (0..10) + .map(|b| lines_block(&format!("b{b}-"), 100)) + .collect(), + ); + let (once, marked) = truncate_tool_output_keyed(msg, &config, None); + assert_eq!(block_lines(&once), vec![MIN_BLOCK_LINES; 10]); + assert_eq!(marked.len(), 10); + assert_eq!( + truncate_tool_output(once.clone(), &config), + once, + "idempotent" + ); + } + #[test] fn test_truncate_tool_output_helper() { let big = (0..500) From 133a8f1a8aabf8062cc3fed534f54a7be77ea970 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Tue, 20 Oct 2026 17:17:09 +0200 Subject: [PATCH 2/3] docs(context): what the shared budget does to earlier history; floor wording Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- docs/concepts/context-management.md | 2 +- src/context.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18b3b6e..a6647cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ adheres to [Semantic Versioning](https://semver.org/). ## Unreleased -- **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 (which sends every block, #347) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks shorter than an even share stay whole, the long ones split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. A cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much, never cut without a marker. One-block results are unchanged; truncation stays idempotent, so already truncated history is not rewritten. +- **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 (which sends every block, #347) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks shorter than an even share stay whole, the long ones split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. With a budget of 5 or more, a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much, never cut without a marker. One-block results are unchanged, and truncation stays idempotent, so history written by this version is never rewritten. A multi-block result stored by an earlier version that is over the budget in total is cut once more, the first time compaction's level 1 runs over it: one prefix-cache rewrite. A stash key its old markers name may be cut out of the context then, though the stash entry itself is kept. ## yoagent-rutis Unreleased diff --git a/docs/concepts/context-management.md b/docs/concepts/context-management.md index a60cb6f8..ef02a82f 100644 --- a/docs/concepts/context-management.md +++ b/docs/concepts/context-management.md @@ -148,7 +148,7 @@ let config = ContextConfig::from_context_window(1_000_000); ### Level 1: Truncate Tool Outputs -Replaces long tool outputs with head + tail, fitting the result into exactly `tool_output_max_lines` lines (the `[... N lines truncated ...]` marker is charged against that budget). The budget is the whole result's: when a result has several text blocks (an MCP server's, say), blocks shorter than an even share stay whole and the long ones split the rest, each cut with its own marker; a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much rather than being cut silently. This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. +Replaces long tool outputs with head + tail, fitting the result into exactly `tool_output_max_lines` lines (the `[... N lines truncated ...]` marker is charged against that budget). The budget is the whole result's: when a result has several text blocks (an MCP server's, say), blocks shorter than an even share stay whole and the long ones split the rest, each cut with its own marker; with a budget of 5 or more, a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much rather than being cut silently. This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. Truncation is idempotent: re-running it on an already-truncated output returns it byte for byte, so a session that sits above the budget does not re-truncate the same outputs turn after turn. diff --git a/src/context.rs b/src/context.rs index d79d713a..0adc6241 100644 --- a/src/context.rs +++ b/src/context.rs @@ -676,9 +676,10 @@ const MIN_BLOCK_LINES: usize = TRUNCATION_MARKER_LINES + 2; /// many-block result (an MCP server's) fits the budget one block would. A /// block is never cut below [`MIN_BLOCK_LINES`] (or `max_lines`, if smaller), /// so a result with more long blocks than the budget can mark goes over it by -/// at most that many lines per block — never silently. +/// at most that many lines per block — never silently, for a budget of 5 or +/// more. /// -/// Idempotent with [`truncate_text_head_tail`]: a cut block is exactly its +/// Idempotent with [`truncate_text_head_tail`]: a cut block is at most its /// budget long, so the result either fits afterwards (and a second pass /// leaves it alone) or every cut block is at the floor, which a second pass /// assigns again. From 75b8e6bed03ea7c5932f143542c543da2ce7d9b5 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Tue, 20 Oct 2026 18:05:26 +0200 Subject: [PATCH 3/3] test+docs(context): pin the shared budget's remainder and slack; exact overshoot wording - Tests: three long blocks share 200 as [67, 67, 66]; a block over the first even share that fits once a shorter one is placed stays whole; an override exempts a many-block result; sixty 4-line blocks stay whole; the loop's append path cuts MultimodalTool's two blocks to [10, 10]. - Docs: overshoot happens when the even share is under 5 lines (short blocks included), and below a budget of 5 nothing is shared; stale "truncated independently" wording updated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- docs/concepts/context-management.md | 2 +- src/context.rs | 101 ++++++++++++++++++++++------ tests/agent_loop_test.rs | 18 +++++ 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6647cb8..2683b527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ adheres to [Semantic Versioning](https://semver.org/). ## Unreleased -- **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 (which sends every block, #347) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks shorter than an even share stay whole, the long ones split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. With a budget of 5 or more, a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much, never cut without a marker. One-block results are unchanged, and truncation stays idempotent, so history written by this version is never rewritten. A multi-block result stored by an earlier version that is over the budget in total is cut once more, the first time compaction's level 1 runs over it: one prefix-cache rewrite. A stash key its old markers name may be cut out of the context then, though the stash entry itself is kept. +- **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 sent every block (#347; Bedrock, and results with an image, sent them before) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks no longer than an even share stay whole, the others split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. A block not left whole keeps at least 5 lines (head, marker, tail; a shorter one stays whole), so when the even share would be under 5 lines — more text blocks than a fifth of the budget — the result goes over the budget by up to 5 lines per such block, never cut without a marker. Below a budget of 5 there is no room for a marker and nothing is shared: each block keeps the budget, as before. One-block results are unchanged, and truncation stays idempotent, so history written by this version is never rewritten. A multi-block result stored by an earlier version that is over the budget in total is cut once more, the first time compaction's level 1 runs over it: one prefix-cache rewrite. A stash key its old markers name may be cut out of the context then (the stash entry itself is kept), and that cut's own marker names no key: compaction does not stash. ## yoagent-rutis Unreleased diff --git a/docs/concepts/context-management.md b/docs/concepts/context-management.md index ef02a82f..ba4f9d0a 100644 --- a/docs/concepts/context-management.md +++ b/docs/concepts/context-management.md @@ -148,7 +148,7 @@ let config = ContextConfig::from_context_window(1_000_000); ### Level 1: Truncate Tool Outputs -Replaces long tool outputs with head + tail, fitting the result into exactly `tool_output_max_lines` lines (the `[... N lines truncated ...]` marker is charged against that budget). The budget is the whole result's: when a result has several text blocks (an MCP server's, say), blocks shorter than an even share stay whole and the long ones split the rest, each cut with its own marker; with a budget of 5 or more, a cut block keeps at least 5 lines (head, marker, tail), so a result with more long blocks than the budget can mark goes over it by that much rather than being cut silently. This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. +Replaces long tool outputs with head + tail, fitting the result into `tool_output_max_lines` lines (exactly, for a single long block; the `[... N lines truncated ...]` marker is charged against that budget). The budget is the whole result's: when a result has several text blocks (an MCP server's, say), blocks no longer than an even share stay whole and the others split the rest, each cut with its own marker. A block not left whole keeps at least 5 lines (head, marker, tail), so when the even share would be under 5 lines the result goes over the budget by up to 5 lines per such block rather than being cut silently. Below a budget of 5 there is no room for a marker, and each block keeps the budget on its own. This is the cheapest level — it preserves conversation structure and typically saves 50-70% in coding sessions. Truncation is idempotent: re-running it on an already-truncated output returns it byte for byte, so a session that sits above the budget does not re-truncate the same outputs turn after turn. diff --git a/src/context.rs b/src/context.rs index 0adc6241..bdf53ffe 100644 --- a/src/context.rs +++ b/src/context.rs @@ -167,7 +167,8 @@ pub struct ContextConfig { pub keep_recent: usize, /// Minimum first messages to always keep pub keep_first: usize, - /// Max lines to keep per tool output, for tools with no override below. + /// Max lines to keep per tool result, shared by its text blocks (see + /// [`truncate_tool_output`]), for tools with no override below. /// /// Tuned for command output — build logs, test runs, `grep` — where /// head+tail is a good cut: the first error is at the top, the summary at @@ -546,8 +547,8 @@ fn fnv1a(bytes: &[u8]) -> u64 { /// The stash key for one content block of a tool result. /// -/// Block-qualified because a result can carry several text blocks, each -/// truncated independently and each getting its own marker. Sharing one key +/// Block-qualified because a result can carry several text blocks, each cut +/// with its own marker (from one budget shared across them). Sharing one key /// across them made every marker resolve to all the blocks concatenated — /// never the one the model had just been told to fetch. pub fn block_key(base: &str, block: usize) -> String { @@ -594,6 +595,9 @@ pub fn block_texts(msg: &AgentMessage) -> Vec<(usize, String)> { /// what a single shared key produced and which silently dropped any non-text /// content sitting between the blocks. /// +/// Line budgets come from the blocks' line counts alone (one budget per result, +/// shared), so the unkeyed and keyed passes cut the same lines. +/// /// Public so a caller implementing their own stash sink can reproduce exactly /// what the loop does: truncate with `None` to learn which blocks carry a /// marker, stash those, then truncate again with the base key. @@ -644,12 +648,15 @@ pub fn truncate_tool_output_keyed( } } -/// Cap a single tool result's text at the tool's line budget, keeping head and -/// tail. +/// Fit a single tool result's text into the tool's line budget, keeping head +/// and tail. /// -/// The budget is the result's, shared by its text blocks: blocks shorter than an -/// even share stay whole, the rest split what is left and are cut head + tail, -/// each with its own marker, never below 5 lines (head, marker, tail). +/// The budget is the result's, shared by its text blocks: blocks no longer +/// than an even share stay whole, and the others split what is left, each cut +/// head + tail with its own marker. With a budget of 5 or more a cut block +/// keeps at least 5 lines (head, marker, tail), so a result with many blocks +/// can go over the budget; below 5, each block keeps the budget on its own and +/// gets no marker. /// /// The budget comes from [`ContextConfig::max_lines_for`], so a tool that /// tolerates head+tail badly can be exempted. Non-tool-result messages pass @@ -664,25 +671,29 @@ pub fn truncate_tool_output(msg: AgentMessage, config: &ContextConfig) -> AgentM /// Lines the truncation marker occupies: a blank line, the marker, a blank line. const TRUNCATION_MARKER_LINES: usize = 3; -/// The fewest lines a cut block keeps: one head line, the marker, one tail -/// line — so every cut block says it was cut. +/// The fewest lines a cut block keeps: one head line, the marker's three lines, +/// one tail line — so every cut block says it was cut (when the budget is at +/// least this large). const MIN_BLOCK_LINES: usize = TRUNCATION_MARKER_LINES + 2; /// Each content block's line budget, from one budget for the whole result. /// /// The text blocks share `max_lines`: when they fit together, every block is -/// left alone; otherwise blocks shorter than an even share stay whole and the -/// rest split what is left evenly (the remainder to the earliest), so a +/// left alone; otherwise blocks no longer than an even share stay whole and +/// the rest split what is left evenly (the remainder to the earliest), so a /// many-block result (an MCP server's) fits the budget one block would. A -/// block is never cut below [`MIN_BLOCK_LINES`] (or `max_lines`, if smaller), -/// so a result with more long blocks than the budget can mark goes over it by -/// at most that many lines per block — never silently, for a budget of 5 or -/// more. +/// block not left whole is given at least [`MIN_BLOCK_LINES`] (so it is cut +/// with a marker, or kept whole if shorter): when the even share is smaller +/// than that, the result goes over `max_lines` by at most `MIN_BLOCK_LINES` +/// per such block, never silently. When `max_lines < MIN_BLOCK_LINES` the +/// floor is `max_lines` itself, so every block keeps the whole budget, as +/// before, and gets no marker. /// /// Idempotent with [`truncate_text_head_tail`]: a cut block is at most its /// budget long, so the result either fits afterwards (and a second pass -/// leaves it alone) or every cut block is at the floor, which a second pass -/// assigns again. +/// leaves it alone) or the share fell below the floor, leaving every block +/// not left whole at most the floor long; a second pass gives each such block +/// at least the smaller of its length and the floor, so nothing is cut again. fn block_budgets(content: &[Content], max_lines: usize) -> Vec { let lines: Vec> = content .iter() @@ -1770,7 +1781,7 @@ mod tests { tool_output_max_lines: 200, ..Default::default() }; - // Twenty blocks of 200 lines: before, each kept 200 (4,000 in all). + // Twenty blocks of 200 lines: a per-block budget would keep 4,000. let many = result_with( (0..20) .map(|b| lines_block(&format!("b{b}-"), 200)) @@ -1822,6 +1833,50 @@ mod tests { ); } + #[test] + fn the_shared_budget_s_remainder_and_freed_slack() { + let config = ContextConfig { + tool_output_max_lines: 200, + ..Default::default() + }; + // 200 over three long blocks: the remainder goes to the earliest. + let three = result_with( + (0..3) + .map(|b| lines_block(&format!("b{b}-"), 300)) + .collect(), + ); + assert_eq!( + block_lines(&truncate_tool_output(three, &config)), + [67, 67, 66] + ); + // 60 lines is over an even share of 150 (50) but fits once the + // 10-line block has left 140 for two (70): it stays whole. + let config = ContextConfig { + tool_output_max_lines: 150, + ..Default::default() + }; + let slack = result_with(vec![ + lines_block("s", 10), + lines_block("m", 60), + lines_block("l", 300), + ]); + assert_eq!( + block_lines(&truncate_tool_output(slack, &config)), + [10, 60, 80] + ); + // A tool exempted by an override stays whole, however many blocks. + let mut config = ContextConfig::default(); + config + .tool_output_max_lines_overrides + .insert("mcp_tool".into(), usize::MAX); + let many = result_with( + (0..20) + .map(|b| lines_block(&format!("b{b}-"), 200)) + .collect(), + ); + assert_eq!(truncate_tool_output(many.clone(), &config), many); + } + #[test] fn more_long_blocks_than_the_budget_can_mark_keep_a_marker_each() { let config = ContextConfig { @@ -1843,6 +1898,14 @@ mod tests { once, "idempotent" ); + // Short blocks past that point stay whole: sixty 4-line blocks keep + // 240 lines of a 200-line budget, none cut. + let config = ContextConfig { + tool_output_max_lines: 200, + ..Default::default() + }; + let short = result_with((0..60).map(|b| lines_block(&format!("b{b}-"), 4)).collect()); + assert_eq!(truncate_tool_output(short.clone(), &config), short); } #[test] diff --git a/tests/agent_loop_test.rs b/tests/agent_loop_test.rs index 3ef7ebf4..9027f807 100644 --- a/tests/agent_loop_test.rs +++ b/tests/agent_loop_test.rs @@ -4211,6 +4211,24 @@ async fn each_text_block_gets_its_own_key_and_resolves_to_itself() { _ => false, }); assert!(has_image, "non-text content must pass through untouched"); + + // The two blocks share the result's 20 lines, rather than taking 20 each. + let lines: Vec = messages + .iter() + .find_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { content, .. }) => Some( + content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.lines().count()), + _ => None, + }) + .collect(), + ), + _ => None, + }) + .expect("a tool result"); + assert_eq!(lines, [10, 10]); } /// Only the second text block is long enough to truncate, so `marked` is