Skip to content

fix(agent): bound code input and tool output byte budgets (#180, #181)#191

Merged
jsnyder merged 7 commits into
mainfrom
fix/agent-budget-bounds
May 2, 2026
Merged

fix(agent): bound code input and tool output byte budgets (#180, #181)#191
jsnyder merged 7 commits into
mainfrom
fix/agent-budget-bounds

Conversation

@jsnyder

@jsnyder jsnyder commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • agent.rs: code under review inserted into prompt without size bound #180: Code under review was inserted into the LLM prompt without any size bound. Added max_code_bytes field to AgentConfig (100KB default) and wrap_code_with_budget() that truncates with in-prompt marker + stderr warning, mirroring the existing wrap_listing_with_budget() pattern.
  • agent.rs: tool byte budget enforced only after full output allocation #181: Tool output was fully allocated in memory before the byte budget was checked. Added max_output_bytes parameter to ToolRegistry::execute() and pushed the cap into each tool: read_file uses Read::take() to limit bytes from disk, grep/list_files use an AccumulatorState byte counter to stop accumulating early.
  • Extracted AccumulatorState struct to bundle recursive accumulator params, fixing a clippy too_many_arguments warning.
  • Fixed UTF-8 boundary truncation: read_to_string with Read::take() errors on mid-codepoint splits; switched to read_to_end + from_utf8 validation.

Implementation plan

docs/plans/2026-05-02-agent-budget-bounds.md

Test plan

  • agent_config_has_max_code_bytes_default — new field exists with 100KB default
  • wrap_code_with_budget_truncates_oversized_input — budget honored with marker
  • wrap_code_with_budget_passes_small_input_unchanged — no truncation under budget
  • wrap_code_with_budget_handles_budget_smaller_than_tags — degenerate case returns empty
  • execute_read_file_respects_max_output_bytes — tool-level cap enforced
  • execute_grep_respects_max_output_bytes — grep respects budget
  • execute_with_large_budget_returns_full_output — no regression with large budget
  • read_file_does_not_allocate_beyond_budget — 1MB file capped at 500 bytes
  • grep_stops_accumulating_at_byte_budget — early termination on byte limit
  • list_files_stops_accumulating_at_byte_budget — same for list_files
  • execute_tool_call_multi_call_budget_accounting — cross-call budget tracking
  • read_file_handles_utf8_boundary_truncation — multi-byte chars at boundary

1758 tests passing, 0 failures. Cargo clippy clean on changed files.

Quorum self-review

4 findings, all pre-existing (complexity in agent_loop, grep_recursive, list_recursive). No in-branch bugs.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable maximum code display size setting to efficiently manage large files.
  • Bug Fixes

    • Improved stability when handling large files and outputs with automatic truncation and clear overflow markers.
    • Enhanced character encoding safety with proper boundary handling for large content.
    • Tool outputs now have enforced size limits to prevent memory issues and ensure consistent performance.

jsnyder and others added 7 commits May 2, 2026 00:15
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace unbounded `wrap_code` with `wrap_code_with_budget` that
truncates oversized code to `AgentConfig.max_code_bytes`, using the
same UTF-8-safe truncation pattern as `wrap_listing_with_budget`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ToolRegistry::execute() now accepts max_output_bytes and truncates its
result before returning, so tool output is bounded at the source instead
of allocating the full result and truncating after-the-fact. The
truncate() helper now reserves marker bytes within the budget so the
total output (body + marker) never exceeds the limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each tool now caps its own internal allocations instead of relying
solely on the boundary truncate() in execute():

- exec_read_file: uses Read::take() to read only budget+1 bytes from
  disk, avoiding full-file allocation for large files
- grep_recursive: tracks accumulated byte count and stops scanning
  when byte_budget is reached
- list_recursive: same byte accumulator pattern as grep

The boundary truncate() in execute() remains as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ments

Bundles results/max_results/total_bytes/byte_budget into a shared struct
used by grep_recursive and list_recursive, reducing both from 8/7 args
to 4 args.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
read_to_string with Read::take() errors on mid-codepoint truncation.
Switch to read_to_end + from_utf8 validation so files with multi-byte
characters at the budget boundary are handled gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements end-to-end byte-budget constraints for code review content. The agent now enforces a configurable max_code_bytes limit when wrapping code regions, and the tool registry propagates remaining byte budget to all tool implementations (read_file, grep, list_files) to truncate outputs at the boundary, ensuring prompt and tool outputs stay within defined limits.

Changes

Code and Tool Output Budgeting

Layer / File(s) Summary
API & Config
src/agent.rs, src/tools.rs
AgentConfig adds max_code_bytes: usize field (default 100,000). ToolRegistry::execute() signature updated to accept max_output_bytes: usize parameter.
Core Truncation Logic
src/agent.rs, src/tools.rs
New wrap_code_with_budget(code, budget) replaces wrap_code(), reserving overhead for XML tags and truncating content at UTF-8 boundaries with CODE_TRUNC_NOTE marker. truncate() utility rewritten to respect character boundaries and reserve space for \n... (truncated) marker.
Tool Implementations
src/tools.rs
exec_read_file reads only up to byte limit before slicing line range. exec_grep and exec_list_files refactored to use shared AccumulatorState that tracks byte budget and match count, stopping accumulation when either limit is reached.
Integration & Wiring
src/agent.rs
agent_review calls list_files with config.max_bytes_read / 2 budget. render_review_prompt and agent_loop use wrap_code_with_budget(). execute_tool_call computes remaining byte budget before dispatching tools.execute().
Tests & Assertions
src/agent.rs, src/tools.rs
Added unit tests for max_code_bytes default, wrap_code_with_budget edge cases (oversized inputs, small budget, unchanged content). Updated test call sites for new execute() signature. Truncation assertions changed from strict suffix matching to substring detection, and byte-budget assertions relaxed to <= max_bytes_read.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • Issue #180: Implements the exact fix to add AgentConfig.max_code_bytes and replace unbounded wrap_code() calls with budget-aware wrap_code_with_budget() in render_review_prompt and agent_loop.
  • Issue #181: Implements the suggested fix to add max_output_bytes budget parameter to ToolRegistry::execute() and make each tool (read_file, grep, list_files) enforce the cap proactively.

Possibly related PRs

  • PR #184: Also modifies agent wrapping of untrusted repository content (code/listings) and refactors tool-output truncation/byte-budgeting logic, sharing overlapping concerns around content safety and budget enforcement.

Poem

A rabbit hops through bytes with care, 🐰
No code too large for budget's snare,
Truncate and mark when limits bend,
UTF-8 safe from start to end,
Budget flows, and tools obey their share.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(agent): bound code input and tool output byte budgets (#180, #181)' clearly summarizes the main change: implementing byte budget constraints for both code input and tool output in the agent system.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/agent-budget-bounds

Review rate limit: 9/10 reviews remaining, refill in 6 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent.rs (1)

218-273: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Error outputs bypass remaining-byte cap and accounting

On Line 269 and Line 272, error strings are returned without truncation, and total_bytes_read is not updated for those branches. Repeated tool/JSON errors can therefore consume prompt space without consuming budget.

Proposed fix
-        let result = match serde_json::from_str::<serde_json::Value>(&tc.arguments) {
+        let raw_result = match serde_json::from_str::<serde_json::Value>(&tc.arguments) {
             Ok(args) => {
                 match tools.execute(&tc.name, &args, remaining) {
-                    Ok(output) => {
-                        // existing truncation/accounting path...
-                        output
-                    }
+                    Ok(output) => output,
                     Err(e) => format!("Error: {}", e),
                 }
             }
             Err(e) => format!("Error: malformed arguments: {}", e),
         };
-        Some(result)
+        let result = truncate(&raw_result, remaining);
+        if raw_result.len() > remaining {
+            self.total_bytes_read = config.max_bytes_read;
+            eprintln!("Agent: byte limit ({}) reached", config.max_bytes_read);
+        } else {
+            self.total_bytes_read += result.len();
+        }
+        Some(result)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent.rs` around lines 218 - 273, The error branches (Err from
tools.execute and malformed args from serde_json::from_str) bypass the
remaining-byte cap and don't update self.total_bytes_read; modify the code that
produces error messages (the format!("Error: {}", e) and malformed args branch)
to run through the same truncation logic used for successful tool output (use
remaining, TRUNCATION_MARKER and floor_char_boundary to truncate and decide
whether to append the marker), and update self.total_bytes_read exactly as in
the truncated_path handling (set to config.max_bytes_read when truncated,
otherwise add the final output length) so all output paths respect the byte
budget; apply this change around the match arms that produce the result value
(the tools.execute Err branch and the serde_json::from_str Err branch).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/tools.rs`:
- Around line 22-25: push currently appends the full entry regardless of
remaining budget, allowing a single large entry to overshoot byte_budget; change
push (and its use of total_bytes/results) to first compute remaining =
byte_budget - *self.total_bytes, return early if remaining <= 0, otherwise
truncate entry to at most remaining - 1 bytes (preserving space for the +1 byte
accounted for), update *self.total_bytes by the actual bytes appended +1, and
push the possibly-truncated string into self.results so traversal stops
respecting the byte budget and avoids spikes from a single large entry.

---

Outside diff comments:
In `@src/agent.rs`:
- Around line 218-273: The error branches (Err from tools.execute and malformed
args from serde_json::from_str) bypass the remaining-byte cap and don't update
self.total_bytes_read; modify the code that produces error messages (the
format!("Error: {}", e) and malformed args branch) to run through the same
truncation logic used for successful tool output (use remaining,
TRUNCATION_MARKER and floor_char_boundary to truncate and decide whether to
append the marker), and update self.total_bytes_read exactly as in the
truncated_path handling (set to config.max_bytes_read when truncated, otherwise
add the final output length) so all output paths respect the byte budget; apply
this change around the match arms that produce the result value (the
tools.execute Err branch and the serde_json::from_str Err branch).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9c35bccf-de05-4f82-82cb-8b5ccc9d725b

📥 Commits

Reviewing files that changed from the base of the PR and between 8c08448 and 537f644.

📒 Files selected for processing (2)
  • src/agent.rs
  • src/tools.rs
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.rs: Do not use emojis in code or output
Support AST-based analysis for Rust files (.rs) including complexity, unsafe code blocks, and unwrap() calls
Use clippy as the linter for Rust files
Include Rust ast-grep rules (5 bundled): block-on-in-async, expect-empty-message, ignored-io-result, silent-error-conversion, string-byte-slice

Files:

  • src/agent.rs
  • src/tools.rs
🔇 Additional comments (2)
src/tools.rs (1)

293-304: UTF-8-safe truncation and marker reservation look correct

Line 298 and Line 302 protect character boundaries, and marker reservation keeps output within max.

src/agent.rs (1)

172-197: wrap_code_with_budget correctly handles tight budgets and UTF-8 boundaries

The wrapper-overhead guard and boundary-safe truncation are aligned with the listing wrapper behavior.

Comment thread src/tools.rs
Comment on lines +22 to +25
fn push(&mut self, entry: String) {
*self.total_bytes += entry.len() + 1;
self.results.push(entry);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Accumulator can overshoot byte budget before stop condition

push always appends the full entry (Line 23-Line 24), so one large grep/list entry can exceed byte_budget significantly before traversal stops. That weakens the early-bound guarantee and can spike memory on long matches.

Proposed fix
 impl AccumulatorState<'_> {
     fn push(&mut self, entry: String) {
-        *self.total_bytes += entry.len() + 1;
-        self.results.push(entry);
+        if self.is_full() {
+            return;
+        }
+        let separator = usize::from(!self.results.is_empty());
+        let remaining = self.byte_budget.saturating_sub(*self.total_bytes);
+        if remaining <= separator {
+            return;
+        }
+
+        let mut entry = entry;
+        let max_entry_bytes = remaining - separator;
+        if entry.len() > max_entry_bytes {
+            let safe_end = entry.floor_char_boundary(max_entry_bytes);
+            entry.truncate(safe_end);
+        }
+
+        *self.total_bytes += entry.len() + separator;
+        self.results.push(entry);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools.rs` around lines 22 - 25, push currently appends the full entry
regardless of remaining budget, allowing a single large entry to overshoot
byte_budget; change push (and its use of total_bytes/results) to first compute
remaining = byte_budget - *self.total_bytes, return early if remaining <= 0,
otherwise truncate entry to at most remaining - 1 bytes (preserving space for
the +1 byte accounted for), update *self.total_bytes by the actual bytes
appended +1, and push the possibly-truncated string into self.results so
traversal stops respecting the byte budget and avoids spikes from a single large
entry.

@jsnyder jsnyder merged commit fd60981 into main May 2, 2026
1 check passed
@jsnyder jsnyder deleted the fix/agent-budget-bounds branch May 2, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant