c05: give read a real output budget instead of a 2000-line fence

The founder's report: one requested file could not be returned in one attempt.
The model-facing `read` defaulted to 2,000 lines OR 50 KiB, whichever came
first, and its schema exposed only path/offset/limit — so raising `limit` did
not raise the output budget, and an ordinary source file was fragmented into
pages with most of its budget unspent. Line-count fragmentation was standing in
for a real output budget.

The budget plumbing already existed (`WorkshopConfig::active_read_result_max_bytes`),
so this is a clamp-range plus schema change, not new machinery.

- Default per-call budget is now 100,000 bytes (Kimi Code parity), and there is
  no line cap at all. `effective_read_max_lines()` is deleted rather than
  re-derived: every retained line costs at least its own newline, so the byte
  budget already bounds the line count.
- The model can raise the budget for one call with `max_bytes`, hard-capped at
  500,000. Over-max requests clamp down; they are never an error.
- Precedence, documented on `effective_read_max_bytes`:
  `max( clamp(workshop_override, 100_000, 2 MiB) or 100_000, min(max_bytes, 500_000) )`.
  Both raising layers can only raise; neither can lower what the other granted.
  2 MiB remains the absolute ceiling.
- The second truncation is gone. `compact_tool_result_for_route` applied a
  12,000-char hard limit (48,000 on >=500K windows) that reduced an
  already-bounded read to a ~900-char head/tail snippet and discarded the
  continuation footer — leaving the model unable to page. A result that
  declares `read_budget_bytes` and stayed inside it now passes through; one
  that overran its own budget still falls through to the ordinary limits.
- Continuation is exact: the footer names the real budget, emits `offset=N`,
  adds the still-unread `limit=M` for a bounded range request, and mentions
  `max_bytes` only while the budget can still go up.
- `description()` and the `read_result_max_bytes` docs now tell the truth; the
  legacy reader's messages no longer claim "50KB" while enforcing 16 KiB.

Read denial, cancellation, binary/encoding handling, edit safety, explicit
offset/limit ranges and the legacy 500-line/16 KiB reader are unchanged.

cargo test -p codewhale-tui --lib --locked -- contract_read read_budget budgeted_read
  test result: ok. 10 passed; 0 failed; 0 ignored; 12602 filtered out
cargo check -p codewhale-tui --all-targets --locked: 0 warnings

Covered: an ordinary file returned whole with no footer; a file of more than
2,000 short lines returned whole (the regression that proves the line cap is
gone); `max_bytes` raising the budget for one call; the three-layer precedence;
a huge single line reporting an exact bash fallback; an oversized file
paginating with an honest footer; offset/limit ranges still exact; and a
budgeted result surviving the context compactor while an unbudgeted one of the
same size does not.

Wider run `-- tools::file large_output compact_tool_result evidence` reports
226 passed; 1 failed. The same filter with these tests skipped reports 217
passed; 1 failed — the identical failure, so it is pre-existing, not from this
slice. See the follow-up filed for it: `read_file_denies_home_credential_path`
fails only under parallel execution because sibling tests mutate process-wide
HOME/CODEWHALE_HOME, and it passes at --test-threads=1.

Out of scope: the shell tool has the same double-truncation shape and was left
alone.

Refs: Linear C05 / SHA-6309.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01X57KRgwqYkmmtVQScczqiL
This commit is contained in:
CodeWhale BotandClaude Opus 5 committed 2026-09-09 18:55:39 -07:00
1 parent f52f7ba335
commit e7f7c71e2c
7 files changed
+313 -71

No files matched your search

+18
View File
@@ -497,6 +497,24 @@ pub(crate) fn compact_tool_result_for_route(
return raw.to_string();
}
// The `read` primitive already bounds itself to an explicit per-call byte
// budget and, when that budget truncates the file, ends with a footer
// naming the exact offset to continue from. Compacting it a second time
// would drop content the caller deliberately budgeted for *and* delete the
// continuation contract, leaving the model with a head/tail snippet and no
// way to page. A result that stayed inside its declared budget therefore
// passes through; one that somehow exceeded it still falls through to the
// ordinary limits below.
if output
.metadata
.as_ref()
.and_then(|metadata| metadata.get("read_budget_bytes"))
.and_then(serde_json::Value::as_u64)
.is_some_and(|budget| raw.len() as u64 <= budget)
{
return raw.to_string();
}
if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) {
return summary;
}
+31
View File
@@ -17164,6 +17164,37 @@ fn evidence_bounded_preview_is_not_recompacted() {
assert!(context.contains("full output at /tmp/art_call.txt"));
}
#[test]
fn budgeted_read_result_is_not_truncated_a_second_time_by_the_context_compactor() {
// C05: `read` bounds itself to an explicit per-call byte budget. The 12K
// context hard limit used to re-truncate that bounded result into a 900-
// char snippet, discarding both the content and the continuation footer.
let content = format!(
"{}\n\n[Showing lines 1-100 of 2000 (100000-byte output budget). Use offset=101 to continue.]",
"r".repeat(90_000)
);
let budgeted = ToolResult::success(content.clone()).with_metadata(json!({
"evidence_routing": "inline",
"read_budget_bytes": 100_000
}));
let passed_through = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &budgeted);
assert_eq!(passed_through, content);
assert!(passed_through.contains("Use offset=101 to continue"));
// The same bytes without a declared budget still take the ordinary path,
// which is what proves the metadata (not the tool name) did the work.
let unbudgeted = ToolResult::success(content.clone());
let compacted = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &unbudgeted);
assert!(compacted.contains("output compacted to protect context"));
// A result that overran its own declared budget is not exempt.
let overrun = ToolResult::success(content).with_metadata(json!({
"read_budget_bytes": 1_000
}));
let compacted_overrun = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &overrun);
assert!(compacted_overrun.contains("output compacted to protect context"));
}
#[test]
fn codex_tool_retention_uses_oauth_route_window_not_asmall_contract_model_window() {
let content = "route-effective context\n".repeat(900);
+82 -54
View File
@@ -497,20 +497,41 @@ pub(crate) fn is_codewhale_credential_path(path: &Path) -> bool {
// === small-contract-compatible primitive implementation helpers ===
const READ_MAX_LINES: usize = 2_000;
const READ_MAX_BYTES: usize = 50 * 1024;
/// Default model-visible byte budget for one `read` call.
///
/// Bytes are the *only* default bound: there is no line cap, so an ordinary
/// source or prose file comes back whole in one call instead of being paged
/// at some arbitrary line count with most of the budget unspent.
const READ_DEFAULT_MAX_BYTES: usize = 100_000;
/// Hard ceiling on a budget the *model* asks for with `max_bytes`. A larger
/// request clamps down to this; it is never an error.
const READ_REQUEST_MAX_BYTES: usize = 500_000;
/// Outer bound on the operator's process-wide `[workshop] read_result_max_bytes`
/// override, and therefore on any read result.
const READ_RESULT_ABSOLUTE_MAX_BYTES: usize = 2 * 1024 * 1024;
fn effective_read_max_bytes() -> usize {
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
.map(|n| n.clamp(READ_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES))
.unwrap_or(READ_MAX_BYTES)
}
fn effective_read_max_lines() -> usize {
match crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes() {
Some(bytes) if bytes > READ_MAX_BYTES => (bytes / 80).clamp(READ_MAX_LINES, 20_000),
_ => READ_MAX_LINES,
/// Resolve the byte budget for one `read` call from the three layers that can
/// set it, highest wins:
///
/// 1. **The model's own request** — `max_bytes` on this call, clamped to
/// [`READ_REQUEST_MAX_BYTES`] (500 000).
/// 2. **The operator's process-wide override** — `[workshop]
/// read_result_max_bytes`, clamped into
/// `[READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES]` (2 MiB).
/// 3. **The default** — [`READ_DEFAULT_MAX_BYTES`] (100 000).
///
/// The result is `max(1, 2-or-3)`. Both raising layers can only raise: a model
/// request never shrinks a budget the operator widened, and an operator who
/// widened it process-wide keeps that floor when the model asks for less.
fn effective_read_max_bytes(requested: Option<usize>) -> usize {
let baseline =
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
.map_or(READ_DEFAULT_MAX_BYTES, |configured| {
configured.clamp(READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES)
});
match requested {
Some(requested) => baseline.max(requested.min(READ_REQUEST_MAX_BYTES)),
None => baseline,
}
}
@@ -603,7 +624,7 @@ fn reject_primitive_unknown(input: &Value, tool: &str, allowed: &[&str]) -> Resu
)))
}
fn contract_line_number(input: &Value, key: &str) -> Result<Option<usize>, ToolError> {
fn contract_nonnegative_int(input: &Value, key: &str) -> Result<Option<usize>, ToolError> {
let Some(value) = input.get(key) else {
return Ok(None);
};
@@ -634,15 +655,18 @@ fn contract_format_size(bytes: usize) -> String {
struct ContractReadWindow {
content: String,
shown_lines: usize,
truncated_by_bytes: bool,
truncated_by_lines: bool,
truncated: bool,
first_line_too_large: bool,
}
/// Retain only complete lines from the head, stopping at its own independent
/// line and UTF-8 byte budgets. A terminal newline is content but does not add
/// a phantom line to the truncation counter.
fn contract_read_window(content: &str) -> ContractReadWindow {
/// Retain only complete lines from the head, stopping at `max_bytes`. A
/// terminal newline is content but does not add a phantom line to the
/// truncation counter.
///
/// The byte budget is the single bound. There is no line cap to fragment a
/// file that fits: every retained line costs at least its own newline, so
/// `max_bytes` already bounds the line count as well.
fn contract_read_window(content: &str, max_bytes: usize) -> ContractReadWindow {
let mut lines = if content.is_empty() {
Vec::new()
} else {
@@ -651,35 +675,29 @@ fn contract_read_window(content: &str) -> ContractReadWindow {
if content.ends_with('\n') {
let _ = lines.pop();
}
let max_bytes = effective_read_max_bytes();
let max_lines = effective_read_max_lines();
if lines.first().is_some_and(|line| line.len() > max_bytes) {
return ContractReadWindow {
content: String::new(),
shown_lines: 0,
truncated_by_bytes: true,
truncated_by_lines: false,
truncated: true,
first_line_too_large: true,
};
}
if lines.len() <= max_lines && content.len() <= max_bytes {
if content.len() <= max_bytes {
return ContractReadWindow {
content: content.to_string(),
shown_lines: lines.len(),
truncated_by_bytes: false,
truncated_by_lines: false,
truncated: false,
first_line_too_large: false,
};
}
let mut kept = Vec::new();
let mut bytes = 0usize;
let mut truncated_by_bytes = false;
for line in lines.iter().take(max_lines) {
for line in &lines {
let next = line.len() + usize::from(!kept.is_empty());
if bytes.saturating_add(next) > max_bytes {
truncated_by_bytes = true;
break;
}
kept.push(*line);
@@ -689,8 +707,7 @@ fn contract_read_window(content: &str) -> ContractReadWindow {
ContractReadWindow {
content: kept.join("\n"),
shown_lines,
truncated_by_bytes,
truncated_by_lines: !truncated_by_bytes,
truncated: true,
first_line_too_large: false,
}
}
@@ -705,10 +722,11 @@ impl ReadFileTool {
input: Value,
context: &ToolContext,
) -> Result<RichToolResult, ToolError> {
reject_primitive_unknown(&input, "read", &["path", "offset", "limit"])?;
reject_primitive_unknown(&input, "read", &["path", "offset", "limit", "max_bytes"])?;
let path_str = required_str(&input, "path")?;
let offset = contract_line_number(&input, "offset")?;
let limit = contract_line_number(&input, "limit")?;
let offset = contract_nonnegative_int(&input, "offset")?;
let limit = contract_nonnegative_int(&input, "limit")?;
let max_bytes = effective_read_max_bytes(contract_nonnegative_int(&input, "max_bytes")?);
// S1/F2: check the caller's own spelling BEFORE `resolve_path`
// canonicalizes it. A workspace symlink `notes.txt` -> a denied vault
// file resolves to the secret's absolute location, and a denial raised
@@ -761,34 +779,40 @@ impl ReadFileTool {
None => available,
};
let selected_content = selected.join("\n");
let window = contract_read_window(&selected_content);
let window = contract_read_window(&selected_content, max_bytes);
let first_display = start + 1;
let mut output = if window.first_line_too_large {
let size = selected.first().map_or(0, |line| line.len());
format!(
"[Line {first_display} is {}, exceeds {} limit. Use bash: sed -n '{first_display}p' {path_str} | head -c {READ_MAX_BYTES}]",
contract_format_size(size),
contract_format_size(READ_MAX_BYTES)
"[Line {first_display} is {}, exceeds the {max_bytes}-byte output budget for this call. Use bash: sed -n '{first_display}p' {path_str} | head -c {max_bytes}]",
contract_format_size(size)
)
} else {
window.content
};
if !window.first_line_too_large && (window.truncated_by_bytes || window.truncated_by_lines)
{
if !window.first_line_too_large && window.truncated {
let last_display = first_display + window.shown_lines.saturating_sub(1);
let next_offset = last_display + 1;
if window.truncated_by_bytes {
output.push_str(&format!(
"\n\n[Showing lines {first_display}-{last_display} of {} (50KB limit). Use offset={next_offset} to continue.]",
all_lines.len()
));
} else {
output.push_str(&format!(
"\n\n[Showing lines {first_display}-{last_display} of {}. Use offset={next_offset} to continue.]",
all_lines.len()
));
// Continuation must be exact: name the next offset, and when the
// caller asked for a bounded range, the part of that range still
// unread. `max_bytes` is only offered while it can still go up.
let mut hint = format!("offset={next_offset}");
if let Some(limit) = limit {
let remaining = limit.saturating_sub(window.shown_lines);
if remaining > 0 {
hint.push_str(&format!(" limit={remaining}"));
}
}
let raise = if max_bytes < READ_REQUEST_MAX_BYTES {
format!(", or max_bytes up to {READ_REQUEST_MAX_BYTES} to read more per call")
} else {
String::new()
};
output.push_str(&format!(
"\n\n[Showing lines {first_display}-{last_display} of {} ({max_bytes}-byte output budget). Use {hint} to continue{raise}.]",
all_lines.len()
));
} else if limit.is_some() {
let consumed = selected.len();
if start + consumed < all_lines.len() {
@@ -806,7 +830,11 @@ impl ReadFileTool {
context.note_file_read(&file_path);
Ok(RichToolResult::plain(
ToolResult::success(output).with_metadata(json!({
"evidence_routing": "inline"
"evidence_routing": "inline",
// The budget this call actually enforced. The context
// compactor honors it so an already-bounded read is never
// truncated a second time on its way into the conversation.
"read_budget_bytes": max_bytes
})),
))
}
@@ -1207,18 +1235,18 @@ fn render_line_window(
// combination can ever reveal the elided middle, so the note must
// not pretend otherwise — name the escape hatch that works.
output.push_str(&format!(
"\n[TRUNCATED] Line {shown_first} alone exceeds 50KB; showing its head + tail. No line window can reveal the middle of one line — use a searched shell slice when needed.\n"
"\n[TRUNCATED] Line {shown_first} alone exceeds the {visible_bytes}-byte output budget; showing its head + tail. No line window can reveal the middle of one line — use a searched shell slice when needed.\n"
));
} else {
let narrower = (shown_last - shown_first).div_ceil(2).max(1);
output.push_str(&format!(
"\n[TRUNCATED] The selected range exceeded 50KB; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. offset={shown_first} limit={narrower}, then advance offset.\n"
"\n[TRUNCATED] The selected range exceeded the {visible_bytes}-byte output budget; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. offset={shown_first} limit={narrower}, then advance offset.\n"
));
}
}
output.push_str("</file>");
// The file tool self-bounds at 50 KiB and carries its own continuation
// The file tool self-bounds at its own byte budget and carries its own continuation
// contract (`next_start_line`), so the large-output spillover envelope
// must never re-wrap a read result with a second, weaker truncation.
ToolResult::success(output).with_metadata(json!({
+168 -13
View File
@@ -51,37 +51,192 @@ async fn read_file_missing_pdftotext_is_a_failed_typed_outcome() {
);
}
#[test]
fn contract_read_exact_line_limit_with_terminal_newline_is_not_truncated() {
let content = (0..READ_MAX_LINES)
/// C05 regression: the reader used to stop at a fixed 2 000 lines even when
/// the byte budget had barely been touched, fragmenting an ordinary file for
/// no reason. Bytes are now the only bound.
#[tokio::test]
async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whole() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let content = (0..5_000)
.map(|index| format!("line-{index}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let window = contract_read_window(&content);
assert!(!window.truncated_by_lines);
assert!(!window.truncated_by_bytes);
assert_eq!(window.shown_lines, READ_MAX_LINES);
assert!(
content.len() < READ_DEFAULT_MAX_BYTES,
"fixture fits the budget"
);
let window = contract_read_window(&content, READ_DEFAULT_MAX_BYTES);
assert!(!window.truncated);
assert_eq!(window.shown_lines, 5_000);
assert_eq!(window.content, content);
let temporary = tempfile::tempdir().expect("tempdir");
std::fs::write(temporary.path().join("many.txt"), &content).expect("fixture");
let context = ToolContext::new(temporary.path());
let result = ReadFileTool::execute_contract_read(json!({"path": "many.txt"}), &context)
.await
.expect("read result");
assert_eq!(result.content, content);
assert!(
!result.content.contains("[Showing lines"),
"no truncation footer"
);
}
#[tokio::test]
async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer() {
let temporary = tempfile::tempdir().expect("tempdir");
let content = "fn main() {\n println!(\"hi\");\n}\n";
std::fs::write(temporary.path().join("main.rs"), content).expect("fixture");
let context = ToolContext::new(temporary.path());
let result = ReadFileTool::execute_contract_read(json!({"path": "main.rs"}), &context)
.await
.expect("read result");
assert_eq!(result.content, content);
}
#[test]
fn contract_read_byte_limit_keeps_only_complete_utf8_lines() {
let first = "é".repeat(20_000);
let second = "z".repeat(20_000);
let window = contract_read_window(&format!("{first}\n{second}\n"));
assert!(window.truncated_by_bytes);
let first = "é".repeat(30_000);
let second = "z".repeat(60_000);
let window = contract_read_window(&format!("{first}\n{second}\n"), READ_DEFAULT_MAX_BYTES);
assert!(window.truncated);
assert_eq!(window.shown_lines, 1);
assert_eq!(window.content, first);
assert!(std::str::from_utf8(window.content.as_bytes()).is_ok());
}
#[test]
fn read_budget_precedence_is_request_then_workshop_then_default() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
crate::tools::large_output_router::WorkshopConfig::install_active(None);
assert_eq!(effective_read_max_bytes(None), READ_DEFAULT_MAX_BYTES);
assert_eq!(effective_read_max_bytes(Some(250_000)), 250_000);
// Above the model-requestable maximum clamps down instead of erroring.
assert_eq!(
effective_read_max_bytes(Some(READ_REQUEST_MAX_BYTES * 10)),
READ_REQUEST_MAX_BYTES
);
// A request below the active baseline leaves the baseline in place.
assert_eq!(effective_read_max_bytes(Some(10)), READ_DEFAULT_MAX_BYTES);
crate::tools::large_output_router::WorkshopConfig::install_active(Some(
&crate::tools::large_output_router::WorkshopConfig {
read_result_max_bytes: Some(700_000),
..Default::default()
},
));
assert_eq!(effective_read_max_bytes(None), 700_000);
assert_eq!(effective_read_max_bytes(Some(200_000)), 700_000);
// The workshop override keeps the 2 MiB absolute ceiling.
crate::tools::large_output_router::WorkshopConfig::install_active(Some(
&crate::tools::large_output_router::WorkshopConfig {
read_result_max_bytes: Some(READ_RESULT_ABSOLUTE_MAX_BYTES * 4),
..Default::default()
},
));
assert_eq!(
effective_read_max_bytes(None),
READ_RESULT_ABSOLUTE_MAX_BYTES
);
crate::tools::large_output_router::WorkshopConfig::install_active(None);
}
#[tokio::test]
async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
let line = "y".repeat(199);
let content = std::iter::repeat_n(line.as_str(), 1_500)
.collect::<Vec<_>>()
.join("\n");
assert!(content.len() > READ_DEFAULT_MAX_BYTES);
assert!(content.len() < READ_REQUEST_MAX_BYTES);
std::fs::write(temporary.path().join("wide.txt"), &content).expect("fixture");
let context = ToolContext::new(temporary.path());
let default_budget = ReadFileTool::execute_contract_read(json!({"path": "wide.txt"}), &context)
.await
.expect("default budget read");
assert!(
default_budget.content.contains("100000-byte output budget"),
"{}",
default_budget.content
);
let raised = ReadFileTool::execute_contract_read(
json!({"path": "wide.txt", "max_bytes": 400_000}),
&context,
)
.await
.expect("raised budget read");
assert_eq!(raised.content, content);
// Above the hard maximum clamps down; the file still fits, so it is whole.
let clamped = ReadFileTool::execute_contract_read(
json!({"path": "wide.txt", "max_bytes": 9_000_000}),
&context,
)
.await
.expect("clamped budget read");
assert_eq!(clamped.content, content);
}
#[tokio::test]
async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
// 999-byte lines: 100 of them plus their 99 separators are 99 999 bytes,
// one under the 100 000-byte budget, so page one is exactly lines 1-100.
let line = "z".repeat(999);
let content = std::iter::repeat_n(line.as_str(), 2_000)
.collect::<Vec<_>>()
.join("\n");
std::fs::write(temporary.path().join("big.txt"), &content).expect("fixture");
let context = ToolContext::new(temporary.path());
let first = ReadFileTool::execute_contract_read(json!({"path": "big.txt"}), &context)
.await
.expect("first page");
let footer = first
.content
.rsplit_once("\n\n")
.expect("footer present")
.1
.to_string();
assert_eq!(
footer,
"[Showing lines 1-100 of 2000 (100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]"
);
let shown = first.content.rsplit_once("\n\n").expect("body").0;
assert_eq!(shown.lines().count(), 100);
// The named continuation offset is exact: page two starts on line 101.
let second =
ReadFileTool::execute_contract_read(json!({"path": "big.txt", "offset": 101}), &context)
.await
.expect("second page");
assert!(
second.content.starts_with(&line),
"second page starts at the named offset"
);
assert!(
second.content.contains("Use offset=201 to continue"),
"{}",
second.content
);
}
#[tokio::test]
async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
std::fs::write(
temporary.path().join("huge.txt"),
"x".repeat(READ_MAX_BYTES + 1),
"x".repeat(READ_DEFAULT_MAX_BYTES + 1),
)
.expect("fixture");
let context = ToolContext::new(temporary.path());
@@ -90,7 +245,7 @@ async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() {
.expect("read result");
assert_eq!(
result.content,
"[Line 1 is 50.0KB, exceeds 50.0KB limit. Use bash: sed -n '1p' huge.txt | head -c 51200]"
"[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]"
);
}
+5 -1
View File
@@ -60,7 +60,7 @@ impl ToolSpec for ReadTool {
}
fn description(&self) -> &'static str {
"Read a text file. Output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files."
"Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from."
}
fn input_schema(&self) -> Value {
@@ -78,6 +78,10 @@ impl ToolSpec for ReadTool {
"limit": {
"type": "number",
"description": "Maximum number of lines to read."
},
"max_bytes": {
"type": "number",
"description": "Output budget in bytes for this one call. Defaults to 100000; values above the 500000 maximum are clamped down rather than rejected, and a value below the active default leaves the default in place."
}
},
"required": ["path"],
+2 -1
View File
@@ -548,7 +548,8 @@ fn primitive_schemas_are_separate_and_small_contract_shaped() {
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
["limit", "offset", "path"]
// `max_bytes` is the model-requested per-call output budget (C05).
["limit", "max_bytes", "offset", "path"]
.into_iter()
.map(str::to_string)
.collect()
+7 -2
View File
@@ -776,8 +776,13 @@ byte ceilings (#5367) raise the model-visible floor after that routing
and never lower it:
- `read_result_max_bytes` — cap for a single `read` / `read_file`
result. Absent keeps the compile-time defaults (50KiB / 2000 lines
for `read`, 16KiB / 500 lines for `read_file`).
result. Absent keeps the compile-time defaults (100000 bytes for
`read`, which has no line cap; 16KiB / 500 lines for `read_file`).
For `read` this is the middle of a three-layer budget: the model's
own per-call `max_bytes` (hard maximum 500000) raises the budget for
one call, this setting raises the floor for the whole process, and
either way 2MiB is the absolute ceiling. Highest wins; neither layer
can lower a budget the other granted.
- `tool_result_max_bytes` — cap for a generic tool result after
spillover. Absent keeps the 12K-character compact floor (48K on
windows ≥500K tokens). Hard cap is 2MiB.