All 44 progress lines are pure dots, final line at 100%, exit 0. Everything is verified. Final report: **Phase 131 — final verification pass (all 4 tasks already complete in working tree)** - **Verified implemented:** `read(offset, limit)` schema + teaching descriptions; rewritten truncation notice; full 6-row dedupe matrix + range validation + `lines: a-b of N` header + slice cap in `_execute_tool`; wire-level `offset`/`limit` on `ChatToolEvent` (absent keys dropped → non-range frames byte-identical); mock-LLM `read the lines` flow; handbook repro (wording + line numbers derived from the real PDF at test time) - **Tests:** `uv run pytest --no-cov` → 3097 passed, exit 0; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov` → 2/2 in isolation (47.6 s); `tests/integration/test_read_line_ranges_handbook.py` → 2/2 (3.5 s); 10-file targeted E2E batch pinning pre-phase surfaces (grep/ls/plain-read/ALREADY_IN_CONTEXT/truncation) → 31/31; `ruff check .` clean; `pyright` 0 errors - **Completion criteria:** all 5 green — (1) exact range lines + `lines:` header, plain reads byte-identical (existing pins pass unchanged) ✅ (2) truncated-then-range succeeds end-to-end on the real handbook; other re-reads get teaching refusals ✅ (3) find-traps answered from real PDF text, no hardcoded wording ✅ (4) coverage + isolated E2E ✅ (5) no behavior change in completed phases ✅ - **Docs:** README + AGENTS reviewed in full. Fixed 3 inconsistencies: README missing `BOR_HISTORY_MAX_TURNS`/`BOR_HISTORY_MAX_CHARS` rows (pre-existing phase-74 gap — env-tunable, unit-pinned, undocumented); `.env.example` missing the same two vars (added); `.env.example` stale `BOR_READ_MAX_CHARS` comment (still said "grep-pointer notice (phase 95)" — updated to the phase-131 workflow notice). AGENTS.md verified consistent — no changes needed. All 67 `.env.example` vars documented, all defaults match `app/config.py`, pages/auth tables match live routes - **Note:** PLAN.md §7.1 still states the retired 46rem chat column (superseded by phase 100, which AGENTS.md rule 5 records correctly) — PLAN.md is off-limits for this pass, flagged only - **Next pending phase:** `132_image_upload_feedback`
10 KiB
Phase 131 — read gains line ranges: grep finds the line, read(offset, limit) reads it
Source: TODO.md L7 — "The agent is struggling with huge documents. It can search for the text it wants and it finds the text, including line number, but then it gets stuck saying 'However, read takes a path, not a line number.' and can't figure out how to read that part of the document. Does the read tool support that? If not, it should. If it does, improve the description so the agent doesn't get stuck. I have a huge test pdf (the DND player's handbook) in test_sources called 'pdf_tests.tar' that you can use for testing. It's a massive pdf. The question the agent got stuck on was 'What are the components of the find traps spell?' which is toward the end of the players handbook so it will need to search and read specific lines. I've been setting BOR_READ_MAX_CHARS to 24000 to keep things relatively quick so the agent realizes quickly its answer isn't in the first few pages."
(LOCKED A2, owner-confirmed 2026-09-27: the parameter names are offset/limit — 1-based line number + line count — mirroring the pi-harness read tool the model was trained on, the phase-70 "harness-aligned surface" anchor.)
Story: n/a (TODO-derived phase, Protocol B append).
Context: app/rag/agent.py — AGENT_TOOLS (L258+: the ls/read/grep pi.dev-shaped schemas; the read schema at ~L304 takes ONLY path; the grep description promises source/path:line: text locators), _execute_tool (L1266+, the read branch at ~L1348: arg resolution, the ALREADY_IN_CONTEXT dedupe on holder.read_docs, the BOR_READ_MAX_CHARS truncation with TRUNCATION_MARKER + READ_TRUNCATION_NOTICE (~L506 — today it points at grep and then dead-ends), the holder.read_truncations tuples), AgentHolder (the read_docs / read_truncations bookkeeping); app/rag/llm.py — ToolCallPiece (the arguments dict the model sends); tests/e2e/mock_llm.py — the scripted tool-call flows (the "use your tools" / multi-read / grep patterns — the module header documents each trigger); tests/unit/test_agent.py — the read-branch unit suite (the ALREADY_IN_CONTEXT pins, the truncation pins); test_sources/players_handbook.pdf (the DND player's handbook from pdf_tests.tar — on disk, untracked); settings.read_max_chars (BOR_READ_MAX_CHARS).
Objective
The agent stops dead-ending on huge documents: read accepts an optional 1-based line offset + line-count limit, the truncation notice and tool descriptions teach the grep → read(offset/limit) workflow, and the dedupe rules allow the ONE case that matters — re-reading a range of a document whose first read was truncated — while still refusing pointless re-reads of text already in context. The owner's repro (the handbook, "find traps", BOR_READ_MAX_CHARS=24000) passes as an integration test.
Dependencies
130_chat_answer_clusters(todo) — pipeline order only; code dependencies are the completed agent surface (phases 70/72/95/118).
Design (shared by all tasks — the executor reads this, not the chat)
- Schema + teaching (task 01):
readgains two OPTIONAL integer params —offset("The first line to read, 1-based. Omit it to read from the beginning of the document.") andlimit("The maximum number of lines to read. Omit it to read as many lines as fit under the character cap.").requiredstays["path"]. Thereaddescription keeps its existing teaching and GAINS the workflow: for a very large document, usegrepto find where your text is (it reports line numbers), thenreadthat part withoffset+limit; re-reading a document already read in full adds nothing.READ_TRUNCATION_NOTICE(L506) is REWRITTEN to end the dead-end: "…The rest is NOT shown. Use grep (pattern) to find what you need — it reports line numbers — then read that part withoffset(1-based first line) andlimit(number of lines)." Thegrepdescription gets the light touch: "read the winner withread— for a large document, use the reported line number asoffset." - Execution (task 02): in
_execute_tool's read branch:- Validation:
offset/limit, when present, must be ints ≥ 1 (NOT bools — the houseisinstance(x, int) and not isinstance(x, bool)guard); a bad value → a teaching refusal echoing the bad value ("offsetandlimitare whole line numbers ≥ 1 — you passed…") — counts in nothing, consumes a round (the phase-72 refusal style). - The dedupe matrix (
known= theread_docs(source, path) set;truncated= the set of (source, path) fromholder.read_truncations):call doc in knownpreviously truncated result plain read(path)no — full read (truncate at the cap as today) plain read(path)yes yes NEW teaching line (no re-emit, no count): "You already have the first {shown} characters of {source}/{path} in your context. To read the rest, find where your text is with grep (it reports line numbers), then call read with offset(1-based first line) andlimit(lines)." —{shown}from the matchingread_truncationstupleplain read(path)yes no ALREADY_IN_CONTEXT(byte-identical to today)read(path, offset, limit)yes yes ALLOWED (the stuck-scenario fix — the range the model names is exactly the part it does NOT have) read(path, offset, limit)yes no ALREADY_IN_CONTEXT(the full text is already in context — the range is a subset)read(path, offset, limit)no — ALLOWED (a range read of an unread doc) offset > total_lines(the doc'slen(content.splitlines())) → teaching refusal: "{source}/{path} is {total_lines} lines long —offset{offset} is past the end. Use a smaller offset (grep reports the exact line numbers)." Counts in nothing.- Result shape: line 1
Document {source}/{path}:+ line 2date: YYYY-MM-DDstay BYTE-IDENTICAL for plain reads (the E2E mock's_READ_RESULT_PREFIXcontract); a RANGE read inserts a third linelines: {offset}-{end} of {total_lines}(the actual end = min(offset + limit − 1, total_lines); the model sees the true extent), then the image-doc marker (as today), then the slice ("\n".join(lines[offset-1 : offset-1+limit])). - The cap still applies to the SLICE:
len(slice) > settings.read_max_chars→ cut +TRUNCATION_MARKER+ the (rewritten) notice; theread_truncationstuple for a range read is(arg, cap, len(slice))— the slice's requested size (documented: for range reads the tuple'schars_totalis the slice length, not the document's). - A range read appends the doc to
read_docsONCE (theknownset prevents a re-append) — so a later plain read of a range-read doc isALREADY_IN_CONTEXT(the model has committed to this doc; the range result'slines:line told it what it holds).
- Validation:
- Handbook repro (task 03): an integration test that imports
test_sources/players_handbook.pdfas a local source (the mock LLM's deterministic embed/summary fakes — the in-processtests/fakes.pypattern; the handbook is ~1.5 MB of text ≈ 1200 chunks — in-process embedding is fast, keep the test under ~60 s) and drives the agent turn "What are the components of the find traps spell?" withBOR_READ_MAX_CHARS=24000: a scripted fake (thetest_agent.pypattern) — round 1 → agreptool call (pattern "find traps"), round 2 (the grep result carries the real line number) → aread(path, offset=<that line>, limit=40)call, round 3 → the answer echoing the read result's content (the phase-94 "read document reached the context" echo convention). Assert the echo contains the handbook's actual find-traps text (grep it from the extracted PDF text in the test — do not hardcode the handbook wording). - Mock flow + E2E (task 04):
tests/e2e/mock_llm.pygains ONE scripted flow (house style — a user-message marker, deterministic reply, module-header docstring entry): the markerread the lines→ request 1: agreptool call (a canned pattern); request 2 (atool-role grep result with asource/path:line:line present): areadtool call withoffset= the parsed line number andlimit= 30; request 3 (the read result present): the answer "Found it at line N." + the first 80 chars of the read result (the existing read-echo shape).tests/e2e/test_read_line_ranges.py(dedicated, in isolation, mock-only): a KB fixture with ONE large doc (> 24000 chars, the marker lineTRAP_COMPONENTS_LINEnear line ~1500),BOR_READ_MAX_CHARS=24000; ask a question containing the marker → assert the SSEtoolframes (grep, then read withoffset/limitargs) and the final answer echoing the marker line; a second test: a plain read of the large doc truncates with the REWRITTEN notice (the notice namesoffset/limit— assert thetool_result/result surface carries it).
Tasks
01_tool_surface.md— the schema, the descriptions, the rewritten truncation notice.02_range_read_execution.md— the read branch: validation, the dedupe matrix, the slicing, the result shape.03_handbook_repro.md— the integration repro (the handbook + the owner's exact question) + the unit matrix.04_mock_flow_e2e.md— the mock-LLMread the linesflow + the dedicated E2E.
Testing & Quality
- Unit: the full dedupe matrix (6 rows), the validation refusals, the slice math (boundaries, offset past EOF, 1-line docs), the result shapes (plain reads byte-identical — the existing pins must pass unchanged), the cap on slices.
- Integration: the handbook repro (the owner's scenario, the owner's
BOR_READ_MAX_CHARS=24000). - E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: >90% on new/modified code (
app/).
Completion Criteria
read(path, offset, limit)returns exactly the named lines with thelines: a-b of Nheader; plain reads are byte-identical to pre-phase- a truncated first read followed by a range read SUCCEEDS (the stuck scenario is gone); every other re-read is refused with the teaching
- the handbook integration test answers the find-traps question from the real PDF text (no hardcoded wording)
uv run pytest --cov=app --cov-report=term-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_read_line_ranges.py -v --no-covgreen in isolation- no behavior change in completed phases (the grep/ls surfaces, the plain-read contract, the
ALREADY_IN_CONTEXTfull-read refusal)