Repository navigation
A cut fs_read says so first, a program reads the whole file, peek takes a start, and a judgement example - #796
Conversation
…es a start, and code mode has a judgement example The 2026-11-01 battery (seven fictional tasks, none told about `code`) answered three right. The failures had four causes: - A 1.5 MB log answered "0 failures" (7). `fs_read` stopped at 200 KB and said so after the text; the dispatch's 24 KB cap kept the head, so the note sat in the spill file's tail and the marker named the 200 KB read as the whole output. The model read on through spill files and took one's end for the log's. Now the notice opens the result, with the file's size and the line to read on from. - A program's `fs_read` hit the same 200 KB cap. The gate now marks a host call (`ToolCtx::for_program`), and `fs_read` there reads up to the host- byte limit (64 MB): the result is a handle and never enters the context window the turn cap protects. - `peek(h, n)` after `find` read a note's head, not the text around the hit: Marco 18 of 25. `peek` takes `start` (a character offset, negative from the end). - Asked which meetings ended without agreement, the model searched for a keyword and made no sub-call: 4 of 10. The description now calls a meaning question judgement and shows `llm_map` over each item's relevant part, in a domain unlike any fixture. With program reads up to 64 MB, a loop catching `LimitError:` could hold one whole result per read in the record, so each read now applies the conversation's own ceiling (`record::RETAINED_BYTES`) while the program runs. Docs: ARCHITECTURE.md (a cut read says so first), CODE-MODE-DESIGN.md §3 (peek), §5 (the record bound), §13 (the battery).
| let mut tool_ctx = Self::call_ctx(cx, id, turn_taint, recorded_taint, context, work, None); | ||
| // `Refuse` is a program's host call and nothing else (`Asking`): its | ||
| // result is a handle, so the tool need not cut it for a context window. | ||
| if asking == Asking::Refuse { | ||
| Arc::make_mut(&mut tool_ctx).for_program = true; | ||
| } |
There was a problem hiding this comment.
for_program's own doc says "Set by Agent::gate for a host call alone; never the model's", but nothing enforces that — unlike host_calls, which call_ctx resets to None explicitly because a child run would otherwise inherit it.
call_ctx builds its ctx with ..(*cx.tools).clone(), so for_program is inherited from the run's base ToolCtx. The one way a call ctx becomes a run's base ctx is subagent.rs:350 (tools: Arc::new(ctx.clone())). If a subagent were ever admitted into a program, every turn call in the child — including fs_read — would see for_program = true and return up to 64 MB straight into the child model's context window, which is exactly what the flag's comment says cannot happen.
That route is closed today, and tested (a_subagent_is_never_a_program_function): Subagent declares no effect(), so it resolves Undeclared and Admission::admits rejects it. So this is latent, not live. But it is one effect() away, and the structural fix is a one-liner — take it as a parameter next to host_calls rather than letting it ride the .. so the claim in the comment is what the code does:
fn call_ctx(..., host_calls: Option<..>, for_program: bool) -> Arc<ToolCtx> {
Arc::new(ToolCtx { ..., host_calls, for_program, ..(*cx.tools).clone() })
}That also drops the Arc::make_mut here.
|
Reviewed against CLAUDE.md's invariants. The change is sound — I checked the security-critical surface first and found nothing wrong there. What I verified rather than assumed:
Two things to weigh at merge; neither changes the shape of the change. 1. 2. The cap is the maximum host-byte limit, not what the program has left. Minor, take or leave: the second example's gate at |
… stamped by call_ctx so no run inherits it, and the battery's rerun is recorded
…under the allow gate already has, and a test's doc comment is back on its test
| let mut text = if selected { | ||
| // The line the cut fell on: every complete line kept, plus the one it | ||
| // bisected, so reading on from it overlaps a little, the safe direction. | ||
| let cut = truncated.then(|| offset + text.matches('\n').count() as u64); |
There was a problem hiding this comment.
cut == offset whenever the kept text has no newline, so the notice names the offset that was just read.
cut = offset + text.matches('\n').count(). When the first kept line is itself longer than cap, the kept text contains no newline, the count is 0, and cut == offset — the read that just happened. FsRead then renders This is not the whole file: read on with offset {line}, and fs_read has no byte offset, so following that instruction returns byte-for-byte the same result and the same notice. The model makes no progress at all.
The new test pins exactly this shape without noticing the advice is circular:
let data = "é".repeat(MAX_OUTPUT_BYTES); // no newline anywhere
...
assert_eq!(cut, Some(1), "the cut fell on the only line");Read at offset = 1, so the notice says "read on with offset 1". The realistic fixtures are a minified JSON log, a one-line CSV, or a 1.5 MB log written without line breaks — the same family as the 2026-11-01 incident this change exists to fix.
The old message (use offset and limit to read more) was unhelpful but not false; this one is an instruction that cannot advance, and the repo's own convention is that an error says what to do instead. cut == offset is distinguishable here and deserves its own sentence — e.g. "line {line} alone is longer than this {cap}-byte limit, so offset cannot read past it" — rather than an offset the model will retry.
|
Second pass on this branch, after the two findings from the first review were addressed ( One medium finding, inline on Everything else I checked held up:
Minor, for the merge summary rather than a change:
Merging is yours to call. |
…ffset gets past it rather than repeat the read, and the judgement example's description check runs without an interpreter
| let mut text = if selected { | ||
| // The line the cut fell on: every complete line kept, plus the one it | ||
| // bisected, so reading on from it overlaps a little, the safe direction. | ||
| let cut = truncated.then(|| offset + text.matches('\n').count() as u64); |
There was a problem hiding this comment.
The cut line is recounted from the kept text, and misreports a cut that lands exactly on a line boundary — which is the one case where the notice gives the model a dead end.
Trace, cap = 20, file = "a"*20 + "\n" + "second line\nthird\n":
- iter 1:
newline = Some(20),take = 21,take.min(cap + 1 - 0) = 21, sobytesis 21 bytes andlinebecomes 2. - loop exits (
21 <= 20false),truncated = true,bytes.truncate(20)drops the newline. textnow holds zero newlines, socut = offset + 0 = Some(1).
call then takes the line == offset branch (builtin.rs:133) and tells the model "line 1 alone is longer than that, so no offset gets past it with fs_read" — but line 1 fit exactly, and offset: 2 would have read the rest. The same trace on "a"*20 + "\n" (file is cap + 1 bytes) reports This is not the whole file when the whole content was returned and only the terminating newline was lost.
This is not as narrow as it looks: it fires whenever a line boundary falls on byte cap exactly, i.e. for any fixed-width log whose record length divides 200,000 (20, 25, 32, 40, 50, 64, 80, 100 bytes…). The new fixture here is 22 bytes/line, so it misses the case. For a program's read cap is the remaining room, an arbitrary number.
The loop already tracks the right answer: at exit line is the line the cut bisected, or — in the boundary case — the first unread line. It agrees with the recount in every bisected case (checked against "one\ntwo\nthree is a long line here\nfour\n" at cap = 12: both give 3) and is correct in the boundary case where the recount is not.
| let cut = truncated.then(|| offset + text.matches('\n').count() as u64); | |
| let cut = truncated.then_some(line); |
Worth a case in a_cut_read_says_so_first_and_a_program_reads_the_whole_file with a line length that divides the cap, asserting the notice names an offset that reads on rather than the dead end.
|
Reviewed against the invariants in The The relaxation itself is justified for the reason given: the result becomes a handle, text leaves a handle only through Tests measure the guarantee rather than asserting it. One finding
Observations, not requests
|
… a cut just before a newline reads on from the next line rather than declare a dead end
| // A program's read becomes a handle, which never enters the context | ||
| // window the turn cap protects, so it may hold the file up to the | ||
| // program's own host-byte limit (`CODE-MODE-DESIGN.md` §5). | ||
| let cap = match ctx.program_room { | ||
| Some(room) => room.min(crate::tool::code::defaults::HOST_BYTES), | ||
| None => MAX_OUTPUT_BYTES, | ||
| }; |
There was a problem hiding this comment.
A cut program read now yields no handle at all, where it used to yield the first 200 KB.
cap is set to exactly room, and then — when the cut happens — the notice is prepended to the text (lines 141-146), so the result is room + ~175 bytes. Back in Run::read, shared() → charge(text.len()) refuses anything with n > host_bytes - bytes, i.e. n > room (code.rs:1215). So a cut program read is refused by the charge by construction, every time:
- fresh program, 100 MB log:
cap = 64 MB, content64 MB + 175→LimitError: host bytes: a result of 67109039 bytes would take this program past its 67108864 (0 held), no handle. - before this PR:
capwasMAX_OUTPUT_BYTES, content200 KB + 60, the charge passed, and the program got a handle over the head of the file tofind/count/peek.
The code comment and ARCHITECTURE.md both say the read is "capped at what the program can still hold", which is off by the notice: room bytes is exactly what it cannot hold once the notice is on the front. code_tests.rs's "the refusal that follows costs a small read" reads as if the refusal were inevitable, but it isn't — it's caused by the notice overhead, and the error the model sees names a byte count with no hint that offset/limit would get it a readable slice.
Two ways out: size the cap below the notice (room.saturating_sub(NOTICE_MAX) with the notice length bounded), or charge only the file bytes and keep the notice out of the handle's length. Either way it would be worth a test at the gate level — the monty test only exercises room = 64 MB on a 300 KB file, so no test covers what a program actually gets back when the cut fires.
|
Reviewed against CLAUDE.md's invariants. One finding, medium, left inline on Everything else checks out, and the parts that could have gone wrong didn't:
Minor, not worth a push: the host-call path still has no |
…ce, so it is a handle the program can hold rather than a refused charge
Review: sound. No major or medium finding.I read Security invariants — intact.
The byte arithmetic holds.
Tests measure the new guarantees. Each of the five would fail with its fix reverted, and Verified here: Minor, for you to weigh at merge — no change requested
Merging is yours. |
The 2026-11-01 battery (seven fictional tasks, none told about
code)answered three right. The failures had four causes:
fs_readstopped at 200 KB andsaid so after the text; the dispatch's 24 KB cap kept the head, so the
note sat in the spill file's tail and the marker named the 200 KB read as
the whole output. The model read on through spill files and took one's
end for the log's. Now the notice opens the result, with the file's size
and the line to read on from.
fs_readhit the same 200 KB cap. The gate now marks a hostcall (
ToolCtx::program_room), andfs_readthere reads up to the host-byte limit (64 MB): the result is a handle and never enters the context
window the turn cap protects.
peek(h, n)afterfindread a note's head, not the text around thehit: Marco 18 of 25.
peektakesstart(a character offset, negativefrom the end).
keyword and made no sub-call: 4 of 10. The description now calls a
meaning question judgement and shows
llm_mapover each item's relevantpart, in a domain unlike any fixture.
With program reads up to 64 MB, a loop catching
LimitError:could holdone whole result per read in the record, so each read now applies the
conversation's own ceiling (
record::RETAINED_BYTES) while the programruns.
Docs: ARCHITECTURE.md (a cut read says so first), CODE-MODE-DESIGN.md §3
(peek), §5 (the record bound), §13 (the battery).
Tests, each failing with its fix reverted:
a_cut_read_says_so_first_and_a_program_reads_the_whole_file(builtin): the notice opens the result with the file's real size, survivescap_resultat 24 KB, names an offset that continues the read, and aprogram_roomread returns the whole file.a_programs_read_holds_the_whole_file(monty, through the real gate): a 300 KB file is one handle of 300,000 characters with no cut.peek_reads_from_a_start_offset: offsets, negative start, an out-of-range start, and the count being what was returned.the_judgement_example_runs_as_written: the description's text runs, one pass per ticket, each pass seeing only the resolution.a_program_that_reads_past_its_byte_limit_holds_a_bounded_record: six reads, five refused, all recorded, two held at a 2,500-byte ceiling.cargo test -p mecha-core --lib3842 passed; clippy-D warningsclean with all features and with--no-default-features.