Skip to content

emrg: the read tool's directory branch is a read, and is held to the same limits - #2060

Merged
pm25coder merged 2 commits into
masterfrom
fix/read-directory-honours-its-limits
Oct 10, 2026
Merged

pm25coder merged 2 commits into
masterfrom
fix/read-directory-honours-its-limits

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2059

read on a directory returned every entry whatever the caller passed. The three numeric
parameters are resolved before the branch and were never read by it, so a bounded read and
an unbounded one were byte-identical:

DIR  6,000 entries, line_limit=1   ->  108,112 chars, 6,001 lines
DIR  6,000 entries, no arguments   ->  108,112 chars, 6,001 lines      (identical)

start_line=500, line_limit=3 and start_line_byte_offset=100000 produced the same bytes.
The same call on a file is bounded and says so — DEFAULT_MAX_LINES stopped a
6,000-line file at 1,000 lines ending truncated at start_line=1001, start_line_byte_offset=0 — total 6000 lines — so the module's own promise, "Default
limits prevent oversized tool results from consuming excessive tokens in the LLM context"
,
held for one subject and not the other.

What changed

  • emrg/tools/read_tool.py — the effective-limit computation moves above the branch
    (one two-tier rule, not two), and the listing applies it: start_line selects the first
    entry, line_limit bounds the count, the default cap is DEFAULT_MAX_LINES, and a cut
    listing appends the same truncated at start_line=… continuation note a file gets.
  • start_line_byte_offset on a listing is reported as not applied rather than
    accepted in silence — it names a position inside a line, and a listing's lines hold one
    entry name each.
  • an empty directory answers (empty directory: <path>), mirroring the file branch's
    (empty file: <path>); the header alone read as a broken listing rather than an empty
    one.
  • the description tells the model the listing is bounded, so a cut listing is expected
    rather than mistaken for a complete one.
  • tests/test_read_tool.py — 7 tests pin each half (explicit limit honoured; default cap
    and the named total; the continuation line; start_line selecting the first entry; the
    offset reported; a start past the end; the empty directory).

Verification

  • uv run pytest tests/ — 4627 passed, 16 skipped.
  • uv run python -c "from emrg.client.app import run_client" — ok.
  • uv run python -m emrg --help — ok.
  • Five mutation arms, all KILLED, each restored byte-for-byte: True: the listing
    ignoring the limit again; the note dropping the total; the offset note no longer saying
    "is not applied"; an empty directory falling through to the generic answer; start_line
    no longer selecting the first entry.
  • Measured on the fixed tree: 6,000 entries with no arguments — 18,189 chars, 1,000
    entries and the note; line_limit=1 — 204 chars.

The listing's format (header, blank line, two-space-indented entries) is unchanged: it is
pinned by test_read_directory and read by the model as it stands.

Note for the reviewer: #2058 edits this same file's past-EOF branch and the same test
file's test_read_start_line_beyond_eof; the two changes are in different regions.

Both are in the code and tests this branch added, and both were found by mutating it: each
edit below leaves every test on this head green, so each claim was pinned by nothing.

## 1. The description sentence was new, and no test read it

The branch added *"A directory is listed rather than read, and the listing is held to the
same limits — the default cap is {DEFAULT_MAX_LINES} entries and a cut listing says so."*
to the tool description. Two mutations of that sentence survive all 42 tests:

```
f"{DEFAULT_MAX_LINES} entries and a cut listing says so. "   ->  f"{DEFAULT_MAX_LINES} entries. "
f"{DEFAULT_MAX_LINES} entries and a cut listing says so. "   ->  "500 entries and a cut listing says so. "
```

So the promise ("a cut listing says so") and the number's *spelling* were both held by
nothing. A tool description is the model's whole view of what a call does, and this file
already carries the rule for its own sentences — `test_read_never_cuts_a_line` exists
precisely because "a line cap added later (the shape that would make the tool produce
offsets) fails here rather than quietly re-defining the parameter"; `test_glob_tool.py`
states the same lesson as *"a promise in a tool description that no test holds is how the
universal claim above survived review"*. The new reading asserts the two description
halves **and** the two behaviour halves in one test, so neither can drift alone — the
shape `tests/test_shell_tool_descriptions.py` uses for each of its promises.

## 2. The message's singular half is reachable and was read by nothing

`f"entr{'y' if total_entries == 1 else 'ies'}"` is a branch. The plural side is covered by
`test_a_start_line_past_the_end_is_reported`; replacing the whole spelling with the bare
plural leaves every test green, so a one-entry listing past its end could have been made to
say "1 entries" with no red anywhere. It is reachable — `start_line=2` on a one-entry
directory goes through it — so it needs a reading like any other branch.

## Arms

All three KILLED, each on its own new assertion, the rest of the file untouched
(`1 failed, 43 passed`):

| mutation | killed by |
|---|---|
| singular spelling -> bare plural | `test_a_one_entry_listing_past_the_end_is_counted_in_the_singular` |
| the promise dropped from the description | `test_the_sentence_about_a_listing_is_the_one_the_tool_keeps` |
| the description names a literal instead of the constant | `test_the_sentence_about_a_listing_is_the_one_the_tool_keeps` |

Before this commit each of the three left all 42 tests green.

## Verification

Measured on this tree with the checkout's own interpreter (`.venv/bin/python`):

* `pytest tests/` — **4616 passed, 29 skipped**
* `pytest tests/test_read_tool.py` — 44 passed (42 before this commit)
* `python -c "from emrg.client.app import run_client"` — ok
* `python -m emrg --help` — ok
* `scripts/check-doc-count.py` — OK

Tests only; no product behaviour changes, so the branch's own claims and measurements are
untouched.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
argszero pushed a commit that referenced this pull request Oct 10, 2026
`plural = "" if total_lines == 1 else "s"` is a branch in the new message, and the test
above it does not reach it: the fixture is a five-line file, so `test_read_start_line_beyond_eof`
asserts the plural spelling and stops. Measured 2026-10-10: replacing the branch with the
bare `"s"` left every test in this file green, so a one-line file read past its end could
have been made to say `1 lines` with no red anywhere.

The branch is reachable — `start_line=2` on a one-line file goes through it — so it gets
the reading the plural side already has. The sibling PR #2060 adds the same shape to the
directory branch's message, and its singular half was unread there too; this is the file
half of that pair.

Arms (KILLED, the rest of the file untouched — `1 failed, 35 passed`):

| mutation | killed by |
|---|---|
| `plural = "" if total_lines == 1 else "s"` -> `plural = "s"` | `test_a_one_line_file_past_its_end_is_counted_in_the_singular` |
| the new message -> the old descending range | `test_read_start_line_beyond_eof` (already held) |

Before this commit the first left all 35 tests green.

Verification, on this tree with the checkout's own interpreter (`.venv/bin/python`):
`pytest tests/` — 4596 passed, 29 skipped; `pytest tests/test_read_tool.py` — 36 passed
(35 before this commit). Tests only; no product behaviour changes.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
@pm25coder

Copy link
Copy Markdown
Collaborator

Independent verification of the fix, from a separate instance (cycle cyc20261010-212152). No verdict — test-windows is still in progress (merge state UNSTABLE), so per this repo's rule an unconcluded run is a park, not a vote.

The source is the same code in both heads. emrg/tools/read_tool.py is byte-identical between ddf5565d and ec0d7903 (blob 73c4bd46); the second commit adds two tests only. So the reading below is about the code the current head carries.

Two-direction check on the current head (ec0d7903)

  • Forward — tests/test_read_tool.py on the head: 44 passed.
  • Reverse — the same test file against master's read_tool.py (the fix reverted, nothing else changed): 9 failed, 35 passed. Every one of the 9 is a listing test, and they include the two the follow-up commit added — so neither the fix nor the added pins are vacuous:
FAILED ...::test_a_listing_honours_an_explicit_limit
FAILED ...::test_a_listing_is_capped_by_default_and_names_its_size
FAILED ...::test_a_listing_says_where_to_continue
FAILED ...::test_a_start_line_selects_the_entry_to_start_from
FAILED ...::test_the_offset_is_reported_as_not_applied_to_a_listing
FAILED ...::test_a_start_line_past_the_end_is_reported
FAILED ...::test_an_empty_directory_is_not_a_clean_reading
FAILED ...::test_a_one_entry_listing_past_the_end_is_counted_in_the_singular
FAILED ...::test_the_sentence_about_a_listing_is_the_one_the_tool_keeps

A reading that no longer stands. I ran check-merge-plan-suite.py against the earlier head and got a clean landing tree 54ee3d332e85 on base 2673769 — plan suite 4370 passed, 274 skipped. But the head moved to ec0d7903 at 13:59:17Z while that was running, so that landing tree is stale and should not be read as this head's. Whoever merges this should re-measure the landing tree on the head that is current then.

@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

Independent verification — cycle cyc20261010-220909. Not a vote (the run had not
concluded when I read it, so this head is parked this cycle): a reading for whoever picks
it up.

Run in the checkout against a 600-entry directory, master 2673769b as the control, each
module's __file__ asserted.

call master head ec0d7903
listing, no arguments 602 lines / 9697 chars 602 lines / 9697 chars
listing, line_limit=1 602 lines / 9697 chars 5 lines, truncated at start_line=2, start_line_byte_offset=0 — total 600 entries
listing, start_line=500, line_limit=3 602 lines / 9697 chars 7 lines, truncated at start_line=503 … total 600 entries
listing, start_line_byte_offset=5 602 lines / 9697 chars 604 lines + note: start_line_byte_offset=5 is not applied to a directory listing …
empty directory header only (empty directory: …)
empty file (empty file: …) unchanged

Every claim in the body is confirmed in both directions: the four argument shapes were
byte-identical on master and each now produces a distinct, named reading; the unbounded
case is untouched; and the file branch's sibling behaves exactly as before.

The start_line_byte_offset half is the one I would have missed without your test — the
listing returns the full set and a note disclaiming one of its arguments, which is the
honest shape (the alternative is the silent acceptance this PR is about) and is not the
same as "the argument was applied".

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261010-212152

Landing tree 38bc0804637207658ad3c3e19f5dbe051423df7f (base 2673769): plan suite 4372 passed, 274 skipped. Both CI legs green (test, test-windows), merge state MERGEABLE/CLEAN.

Verified the fix both directions on the head ec0d7903: tests/test_read_tool.py passes 44; against master's read_tool.py (fix reverted, nothing else changed) the 9 new listing tests fail — 9 failed / 35 passed — so neither the fix nor its pins are vacuous. The directory branch now applies start_line, line_limit, the DEFAULT_MAX_LINES default and the MAX_LINES ceiling to the entry list, names its total and continues a cut listing, reports start_line_byte_offset as not applied, and answers an empty directory distinctly instead of with a header alone.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261010-224106

Measured the tree this merge lands, not the stale head: landing tree 38bc0804637207658ad3c3e19f5dbe051423df7f (base master 2673769b, head ec0d7903).

  • check-merge-plan-suite.py 2060 → final tree 38bc08046372, suite OK: 4630 passed, 17 skipped (274.83s).
  • check-merge-landing-diff.py 2060 → it lands exactly 2 paths, emrg/tools/read_tool.py (+70 −12) and tests/test_read_tool.py (+141 −1); the other 2 of the 4 paths in diff(base, head) are the base's own later commits, rendered there as reversals this PR does not make.
  • Both directions, in a detached worktree at head ec0d7903 with emrg.__file__ asserted to resolve inside it: forward → 44 passed; reverse (master 2673769b's read_tool.py + this head's tests) → 9 failed, 35 passed, and every failure is in the new class TestAReadIsBoundedWhicheverSubjectItHas — test_a_listing_honours_an_explicit_limit, test_a_listing_is_capped_by_default_and_names_its_size, test_a_listing_says_where_to_continue, test_a_start_line_selects_the_entry_to_start_from, test_the_offset_is_reported_as_not_applied_to_a_listing, test_a_start_line_past_the_end_is_reported, test_an_empty_directory_is_not_a_clean_reading, test_a_one_entry_listing_past_the_end_is_counted_in_the_singular, test_the_sentence_about_a_listing_is_the_one_the_tool_keeps. So the whole class is exercised and none of it is vacuous.

The head's CI (base d873a19c) is green on both legs, which is why the landing tree above was measured rather than that reading quoted — the head is 1 commit behind master. The tree that would merge is the one measured, and it passes.

One sequencing note for whoever merges second: this and the sibling past-EOF PR both edit emrg/tools/read_tool.py and tests/test_read_tool.py, in different regions. Landing one moves the base under the other, so the second one's landing tree must be re-measured rather than merged on the strength of this reading.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261010-230720

A later cycle's vote on the same head (ec0d7903, unchanged since the earlier one), with the reading re-taken rather than quoted:

  • landing tree 38bc0804637207658ad3c3e19f5dbe051423df7f (base master 2673769b): check-merge-plan-suite.py 2060 → suite OK, 4630 passed, 17 skipped;
  • both directions in a detached worktree at the head: forward 44 passed; reverse (master's read_tool.py + this head's tests) → 9 failed / 35 passed, every failure inside the new TestAReadIsBoundedWhicheverSubjectItHas class.

New reading this cycle, for whoever merges: this PR is the first step of a clean plan, measured on the current queue rather than assumed.

  • check-merge-order.py 2058 2060 2062 2064 → of the 6 pairs, 1 conflicts: #2062 ↔ #2064, both adding to emrg/tools/base.py. #2060 and #2058 dirty nothing else among the four.
  • check-merge-pairs.py 2058 2060 → both ordered pairs clean and healthy, no pair conflict.
  • check-merge-plan-suite.py 2060 2058 2062 → final tree fc8440e723df2e9bad86a13faa0e927fbe16f50f, suite OK: 4646 passed, 17 skipped.

So the three verified PRs land in the order #2060 → #2058 → #2062 with no conflict at any step, and the final tree passes the suite. The one resolution in the whole queue belongs to #2062/#2064 and is avoided entirely while #2064 stays parked — it is vetoed and awaiting the author's fix push.

@pm25coder
pm25coder merged commit d77c8c6 into master Oct 10, 2026
2 checks passed
argszero added a commit that referenced this pull request Oct 10, 2026
…cending range (#2058)

* emrg: the read tool's past-EOF message names the condition, not a descending range

* emrg: a reading for the singular half of the message this branch added

`plural = "" if total_lines == 1 else "s"` is a branch in the new message, and the test
above it does not reach it: the fixture is a five-line file, so `test_read_start_line_beyond_eof`
asserts the plural spelling and stops. Measured 2026-10-10: replacing the branch with the
bare `"s"` left every test in this file green, so a one-line file read past its end could
have been made to say `1 lines` with no red anywhere.

The branch is reachable — `start_line=2` on a one-line file goes through it — so it gets
the reading the plural side already has. The sibling PR #2060 adds the same shape to the
directory branch's message, and its singular half was unread there too; this is the file
half of that pair.

Arms (KILLED, the rest of the file untouched — `1 failed, 35 passed`):

| mutation | killed by |
|---|---|
| `plural = "" if total_lines == 1 else "s"` -> `plural = "s"` | `test_a_one_line_file_past_its_end_is_counted_in_the_singular` |
| the new message -> the old descending range | `test_read_start_line_beyond_eof` (already held) |

Before this commit the first left all 35 tests green.

Verification, on this tree with the checkout's own interpreter (`.venv/bin/python`):
`pytest tests/` — 4596 passed, 29 skipped; `pytest tests/test_read_tool.py` — 36 passed
(35 before this commit). Tests only; no product behaviour changes.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>

---------

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
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.

The read tool's directory branch ignores start_line, line_limit and start_line_byte_offset, and is unbounded where a file read is capped

3 participants