mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
f52f7ba335
commit
e7f7c71e2c
7 files changed
+313
-71
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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]"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user