phase: 131_read_line_ranges
Build and Push Containers / build-and-push-app (push) Successful in 2m30s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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`
This commit is contained in:
2026-09-28 11:49:55 -04:00
parent c88c65641a
commit 101156bada
39 changed files with 3521 additions and 108 deletions
@@ -0,0 +1,10 @@
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`
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All gates green. Task complete — final report:
**Phase 131 · Task 01 (tool surface) — done**
- `read` schema: optional integer `offset` (1-based first line) + `limit` (line count) with the spec'd teaching descriptions; `required` stays `["path"]`
- `read` description: truncation dead-end paragraph replaced with the grep → `read(offset, limit)` workflow
- `READ_TRUNCATION_NOTICE` tail rewritten ("grep reports line numbers — then read that part with `offset` … `limit`"); old dead-end sentence gone
- `grep` description: "read the winner with `read` — for a large document, use the reported line number as `offset`"
- Pins updated: `tests/unit/test_agent.py` (schema/description/notice), `tests/integration/test_agent_tools.py` (surface)
- `tests/e2e/test_read_truncation_cap.py`: DOM assertions now use backtick-stripped `NOTICE_RENDERED` (markdown renderer turns `` `offset` `` into `<code>`; the raw copy is unchanged on the wire)
- README: tool table row + `BOR_READ_MAX_CHARS` line updated; AGENTS.md: no doc impact
**Gates:** `uv run pytest --cov=app` → 3061 passed, app/ 99%; `uv run ruff check .` → clean; `uv run pyright` → 0 errors; affected E2E (truncation cap, harness tools, grep teaching + 6 agent-surface files, 34 tests) green in isolation
**Notable:** `prompts.py` `TOOLS_SECTION` (sha256-locked in `test_prompt_lock.py`) still carries the old dead-end sentence — the task scoped task 01 to `agent.py`'s surface only, so I left the byte-locked prompt contract untouched; flag for the phase's final consistency pass.
**Next pending task:** `.agents/phases/todo/131_read_line_ranges/02_range_read_execution.md`
@@ -0,0 +1,114 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 374 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 5152 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All gates green. Task 02 complete.
**Phase 131 · Task 02 (range-read execution) — done**
- `read` branch now executes optional `offset`/`limit`: new `_read_range_args` validator (house int-not-bool ≥1 guard) → pinned `BAD_RANGE_ARGS` refusal with values echoed, fired before dedupe/resolve
- Six-row dedupe matrix: raw-arg fast path (plain+known) split on a resolved-pair truncated set → `REREAD_TRUNCATED` teaching (shown from the matching tuple) or byte-identical `ALREADY_IN_CONTEXT`; range+known+truncated PROCEEDS (the stuck-scenario fix), range+known+full → `ALREADY_IN_CONTEXT`; `read_docs` appends at most once
- Slice math (`first`/`end` with limit-clamp, 1-line docs) + `OFFSET_PAST_END` refusal naming the true line count; range results carry `lines: a-b of N` + marker placement; cap applied to the SLICE with the slice-length `chars_total` tuple (documented on `AgentHolder`)
- Plain reads byte-identical: all 119 pre-existing pins pass unmodified
- 32 new unit tests: constant pins, 10-case validator param, flow refusals, 6 matrix rows (one test each), slice math, image-marker shape, cap-on-slice + `ToolResultPiece`
**Gates:** `uv run pytest` → 3093 passed, app/ coverage 99% (gate >90%); `uv run pytest tests/unit` → 2397 passed; `uv run ruff check .` clean; `uv run pyright` 0 errors; read/grep E2E suites in isolation (truncation-cap, agent-tools ×2, grep-teaching, harness-tools, path-teaching, big-read) → 22 passed
**Docs:** no doc impact (README/AGENTS.md updated in task 01; task 02 adds no user-visible settings)
**Notable:** the task's literal slice formula `min(offset + (limit or 1) − 1, …)` contradicted its own "offset without limit reads to the end" rule and the mandated "offset=1/limit=None → whole doc" test — implemented the documented semantics (`limit is None → end = total_lines`). One pre-existing uncovered line in phase-94 `_folder_exists_in` (defensive empty-folder branch, unreachable via callers) left as-is — not this task's code; coverage gate green.
**Next pending task:** `.agents/phases/todo/131_read_line_ranges/03_handbook_repro.md`
@@ -0,0 +1,114 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
..................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 5190 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All gates green. The previous rejection was a process-level failure (the child run ended without emitting a report), not a code failure — the implementation was complete. I've re-verified the entire suite.
**Phase 131 · Task 02 (range-read execution) — done**
- `read` branch executes optional `offset`/`limit`: new `_read_range_args` validator (house int-not-bool ≥1 guard) → pinned `BAD_RANGE_ARGS` refusal echoing the bad values, fired before dedupe/resolve
- Six-row dedupe matrix: raw-arg fast path (plain+known) split on a resolved-pair truncated set → `REREAD_TRUNCATED` teaching (shown from the matching tuple) or byte-identical `ALREADY_IN_CONTEXT`; range+known+truncated PROCEEDS (the stuck-scenario fix), range+known+full → `ALREADY_IN_CONTEXT`; `read_docs` appends at most once
- Slice math (`first`/`end` with limit-clamp, 1-line docs, offset-past-EOF) + `OFFSET_PAST_END` refusal naming the true line count; range results carry `lines: a-b of N` + image-marker placement; cap applied to the SLICE with slice-length `chars_total` tuple (documented on `AgentHolder`)
- Plain reads byte-identical: all 119 pre-existing read/truncation pins pass unmodified
- 32 new unit tests: constant pins, 10-case validator param, flow refusals, 6 matrix rows (one test each), slice math, image-marker shape, cap-on-slice + `ToolResultPiece`
**Gates (all green):** `uv run pytest --cov=app --cov-report=term-missing` → 3093 passed, app/ coverage 99% (gate >90%); `uv run pytest tests/unit` → 2397 passed (agent.py: 151); `uv run ruff check .` clean; `uv run pyright` 0 errors; read-related E2E suites in isolation (truncation-cap, agent-tools, grep-teaching, path-teaching) → 11 passed
**Docs:** no doc impact (README/AGENTS.md updated in task 01; task 02 adds no user-visible settings/commands)
**Notable:** the task's literal slice formula `min(offset + (limit or 1) − 1, …)` contradicted its own "offset without limit reads to the end" rule and the mandated "offset=1/limit=None → whole doc" test — implemented the documented semantics (`limit is None → end = total_lines`). One pre-existing uncovered line in phase-94 `_folder_exists_in` (defensive empty-folder branch, unreachable via callers) left as-is — not this task's code; coverage gate green.
**Next pending task:** `.agents/phases/todo/131_read_line_ranges/03_handbook_repro.md`
@@ -0,0 +1,114 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
..................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 5190 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
All done. My contribution is the single new file `tests/integration/test_read_line_ranges_handbook.py` (the other working-tree changes are the completed tasks 01/02, pending the harness's atomic phase commit).
**Report — task 03 (handbook repro):**
- **Implemented** `tests/integration/test_read_line_ranges_handbook.py` (the phase's integration suite):
- Fixture: copies the untracked `test_sources/players_handbook.pdf` (never mutates it) into a temp `test_sources/` dir, registers it as a local `git_sources` row, indexes it through the **real** `import_sources` with the in-process `FakeEmbedder` (pdf token added to the code-default extensions, owner's `read_max_chars=24000`); house `TRUNCATE` pattern so the whole-KB grep sees exactly the handbook.
- `HandbookAgentLLM` — scripted round-pattern fake that parses the REAL grep-reported line (never hardcoded) and answers with the read result verbatim (phase-94 echo).
- Test 1 (grep-first): exactly 3 rounds, `tool_calls == 2`, read call carries `offset == grep line` / `limit == 40`, `lines: a-b of N` extent, answer contains the real find-traps text.
- Test 2 (plain-read-first): plain read truncates at 24000 with the REWRITTEN notice (+ `ToolResultPiece`), grep, then the truncated-then-range matrix row PROCEEDS end-to-end (no `REREAD_TRUNCATED`), 4 rounds, `tool_calls == 3`.
- Expected passage derived at test time via `extract_pdf_text` + `agent.grep_document` (first 60 chars of the passage at the first match) — no hardcoded handbook wording.
- **Tests/lint/coverage (all green):** `uv run pytest tests/integration/test_read_line_ranges_handbook.py -v` → 2 passed in 3.7 s (≪ 60 s bar); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors; `uv run pytest --cov=app --cov-report=term-missing` → **3095 passed**, `app/` coverage **99%**.
- **Docs:** no doc impact (test-only task; no user-visible setup/config/feature change).
- **Deviations:** none.
- **Next pending task:** `.agents/phases/todo/131_read_line_ranges/04_mock_flow_e2e.md`.
@@ -0,0 +1,114 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
....................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 5190 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,11 @@
Everything is green again. My contribution remains the single new file `tests/integration/test_read_line_ranges_handbook.py` (already in the tree from the rejected attempt; this run re-verified the whole project).
**Report — task 03 (handbook repro), retry:**
- **In place:** `tests/integration/test_read_line_ranges_handbook.py` — the owner's repro on the real `players_handbook.pdf` via the real `import_sources` + `FakeEmbedder` (temp source dir copy, local `git_sources` row, `read_max_chars=24000`); scripted fake parses the REAL grep-reported line (no hardcoded line/wording) and echoes the read result verbatim.
- Test 1 (grep-first): exactly 3 rounds, `tool_calls == 2`, read call `offset == grep line` / `limit == 40`, `lines: a-b of N` extent, answer contains the REAL find-traps text (derived via `extract_pdf_text` + `agent.grep_document`, first 60 chars).
- Test 2 (plain-read-first): plain read truncates at 24000 with the rewritten notice + `ToolResultPiece`; the truncated-then-range matrix row PROCEEDS end-to-end (no `REREAD_TRUNCATED`); 4 rounds, `tool_calls == 3`.
- **Results (all green):** `uv run pytest tests/integration/test_read_line_ranges_handbook.py -v --no-cov` → 2 passed in 3.5 s (≪ 60 s bar); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, exit 0; `uv run pytest --cov=app --cov-report=term-missing` → **3095 passed**, `app/` coverage **99%** (>90%).
- **Docs:** no doc impact (test-only task — no user-visible setup/config/feature change).
- **Deviations:** none; prior rejection was a child-process report-capture failure (its own harness validation of the full suite was 100% green) — no code change was needed or made.
- **Next pending task:** `.agents/phases/todo/131_read_line_ranges/04_mock_flow_e2e.md`.
@@ -0,0 +1,114 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
....................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 262 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 400 0 100%
--------------------------------------------------
TOTAL 5190 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
**Task 04 complete** — phase 131's final task.
**Implemented**
- `tests/e2e/mock_llm.py`: new scripted `READ_LINES_TRIGGER` ("read the lines") flow in house style — 0 tool results → `grep(pattern="LINE_MARKER")`; grep match line present → `read(path, offset=<parsed line>, limit=30)` (the owner's exact dance); read result present → byte-stable answer `Found it at line N. <first 80 chars of the read slice>` (N parsed from the echoed read call's arguments; header `date:`/`lines:` lines skipped so the quote is the pure slice) + module-header docstring entry.
- `tests/e2e/test_read_line_ranges.py` (new, mock-only, module app on port 8148, owner's `BOR_READ_MAX_CHARS=24000`): ONE ~99 KB generated doc `linedoc/big.md` (2000 numbered lines, marker at the 1500th numbered line = line 1652 — first 24000 chars hold no marker; true line computed, never literal). Test 1 pins the SSE `tool` frames in order (grep; read with REAL `offset`/`limit` ints on the frame's args), no `tool_result` frame, non-deflected done + cited read doc, grounded echo (`Found it at line 1652.` + marker text, no truncation dead-end). Test 2 reuses the mock's `READ_CAP_TRIGGER` plain read: `tool_result` frame (`truncated`, 24000/99118) in pinned order + the REWRITTEN notice (names `offset` + `limit`) with the old dead-end wording asserted GONE.
**Results (all green, final state)**
- `uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov` → 2 passed (47.7 s, isolated, DB up)
- `uv run pytest tests/e2e/test_read_truncation_cap.py tests/e2e/test_search_tool.py tests/e2e/test_grep_regex_teaching.py tests/e2e/test_summary_seed_context.py --no-cov` → 10 passed (existing flows unchanged)
- `uv run pytest --cov=app --cov-report=term-missing` → **3097 passed**, `app/` **99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Docs:** no doc impact — README already documents `read(offset?, limit?)` + rewritten notice (tasks 01–02); the frame's `offset`/`limit` contract lives in `ChatToolEvent`'s docstring (wire work was already in the tree). AGENTS.md unchanged.
**Decisions:** trigger "read the lines" is disjoint from every existing trigger (verified by grep); mock-calibrated gate (0.30/0.15/0.15) pins both turns GROUNDED on the single-doc KB (best-chunk cosine ~0.23 verified against the real chunker + mock embedder); 120–150 s timeouts accommodate the 24000-char echo stream (~40 s at mock pacing).
**Next pending task:** none in `todo/` — phase 131 completes with this task.
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+3 -1
View File
@@ -40,6 +40,8 @@ BOR_RELEVANCE_THRESHOLD=0.62 # answer when best cosine >= this OR an FTS hi
# BOR_RELATED_MAX_DOCS=2 # cap on the related-doc tier in the done frame (phase 113: the UI's de-emphasized "nearby docs" row, never a citation chip); 0 = no related docs
# BOR_SUGGESTED_DOCS=5 # cap on the "start here" suggestion tier (phase 118): top-N doc summaries seeded into the grounded prompt; NO cosine floor (opt-in starting points, not citations); >= 1
# BOR_SUGGESTION_PREVIEW_CHARS=400 # NULL-summary fallback preview in a suggestion block (phase 118): first N content chars + the truncation marker; > 0
# BOR_HISTORY_MAX_TURNS=40 # prior client-provided history turns kept per chat turn (phase 74, newest first); 0 = no history (pre-phase-74 two-message requests)
# BOR_HISTORY_MAX_CHARS=24000 # total char budget for the kept history (phase 74: text + thinking per turn); an overflowing turn is dropped whole, never cut mid-answer; 0 = no history
BOR_MAX_OUTPUT_TOKENS=32768 # max answer length in tokens (answers must not be cut off)
BOR_STEERING_MAX_CHARS=8000 # char budget for the <tuning> (steering notes) prompt section
BOR_SUMMARY_MAX_CHARS=12000 # cap on document content sent to the lite summary model (phase 30)
@@ -65,7 +67,7 @@ BOR_NAME_HIT_BONUS=0.005 # selection-time bonus a name-hit doc's best f
# --- Agent document tools (grounded turns may extend context: ls / read / grep) ---
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)
# BOR_READ_MAX_CHARS=128000 # cap on a `read` result (chars); over-cap reads truncate + a grep-pointer notice (phase 95)
# BOR_READ_MAX_CHARS=128000 # cap on a `read` result (chars); over-cap reads truncate + a notice that teaches the grep -> read(offset, limit) workflow (phase 95, rewritten phase 131)
# --- Import scope (A9 default; ANY well-formed extension is allowed) ---
# Comma-separated file extensions (lowercase, no dot) the importer reads.
+4 -2
View File
@@ -331,7 +331,7 @@ server-side tools — on **grounded** (high-relevance) turns only:
| Tool | Description |
|------|-------------|
| **`ls`** | Lists the knowledge base as a **drill-down tree**, one level at a time: no `path` = every source (document count + stored description), a source name = that source's top level, a `source/folder` path = one level deeper; file lines are `source: X \| path: Y \| title: Z` |
| **`read(path)`** | Appends one indexed document's text to context by its combined `source/path`; very large documents are truncated at `BOR_READ_MAX_CHARS` with a notice that points at `grep` to find the rest |
| **`read(path, offset?, limit?)`** | Appends one indexed document's text to context by its combined `source/path`; optional `offset` (1-based first line) + `limit` (line count) read just that part of a large document; very large reads are truncated at `BOR_READ_MAX_CHARS` with a notice that teaches the workflow — grep reports line numbers, then read that part with `offset` + `limit` |
| **`grep(pattern, path?)`** | Searches indexed documents for an exact string (case-insensitive fixed substring); returns up to 20 `source/path:line: text` matches; an optional `path` limits to one document |
| **`show_image(path)`** | *(only when `BOR_ANSWER_IMAGES=1`)* shows one standalone image document as part of the answer — image documents only, at most one per answer |
@@ -633,6 +633,8 @@ to `.env` and adjust.
| `BOR_RELATED_MAX_DOCS` | `2` | Cap on the related-doc tier (the de-emphasized "nearby docs" row — never a citation chip); `0` = no related docs |
| `BOR_SUGGESTED_DOCS` | `5` | Cap on the "start here" suggestion tier (phase 118): top-N doc summaries seeded into the grounded prompt; no cosine floor; ≥ 1 |
| `BOR_SUGGESTION_PREVIEW_CHARS` | `400` | NULL-summary fallback preview in a suggestion block (phase 118): first N content chars + truncation marker; > 0 |
| `BOR_HISTORY_MAX_TURNS` | `40` | Prior client-provided history turns kept per chat turn (phase 74, newest first); `0` = no history (the pre-phase-74 two-message requests) |
| `BOR_HISTORY_MAX_CHARS` | `24000` | Total char budget for the kept history — `len(text) + len(thinking)` per turn (phase 74); a turn that would overflow is dropped whole, never cut mid-answer; `0` = no history |
| `BOR_MAX_OUTPUT_TOKENS` | `32768` | Max answer length in tokens (answers must not be cut off) |
| `BOR_STEERING_MAX_CHARS` | `8000` | Char budget for the `<tuning>` (steering notes) prompt section |
| `BOR_SUMMARY_MAX_CHARS` | `12000` | Cap on document content sent to the `lite` summary model |
@@ -665,7 +667,7 @@ to `.env` and adjust.
| Env | Default | Meaning |
|-----|---------|---------|
| `BOR_AGENT_MAX_ROUNDS` | `10` | Hard cap on agent tool rounds per grounded turn (0 = no tools) |
| `BOR_READ_MAX_CHARS` | `128000` | Cap on a `read` tool result (chars); over-cap reads truncate + a grep-pointer notice (phase 95) |
| `BOR_READ_MAX_CHARS` | `128000` | Cap on a `read` tool result (chars); over-cap reads truncate + a notice that teaches the grep → `read(offset, limit)` workflow (phase 95, rewritten phase 131) |
### Import & sources
+42 -1
View File
@@ -303,6 +303,20 @@ def get_llm() -> LLMClient:
return _llm
def _whole_line_number(value: Any) -> bool:
"""A ``read`` range argument's whole-line-number guard (phase 131,
task 04).
The same validation the agent's ``_read_range_args`` applies
server-side (``app/rag/agent.py``): an ``int`` (bools excluded —
the house guard) and ``>= 1``. A value that fails is a REFUSED
call (the ``BAD_RANGE_ARGS`` teaching), and its ``tool`` frame
carries no range — the refusal surfaces as the round's result, not
as the call's arguments.
"""
return isinstance(value, int) and not isinstance(value, bool) and value >= 1
def sse_event(payload: dict[str, Any]) -> str:
"""Serialize one SSE frame: ``data: <json>\\n\\n`` (PLAN §4)."""
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -905,8 +919,35 @@ async def chat(
"pattern" if piece.name == "grep" else "path"
)
argument = argument if isinstance(argument, str) else None
# Phase 131 (task 04): a range ``read``'s line
# range rides the SAME frame — the additive
# ``offset`` / ``limit`` fields (the wire's
# assertion surface for the owner's
# grep → read(offset/limit) dance). Only a
# well-formed range (``_whole_line_number``,
# the agent's own guard) lands on the frame;
# absent → omitted on serialization, so every
# non-range frame stays byte-identical to
# pre-phase (the ChatToolEvent serializer).
offset = (
piece.arguments.get("offset")
if piece.name == "read"
else None
)
offset = offset if _whole_line_number(offset) else None
limit = (
piece.arguments.get("limit")
if piece.name == "read"
else None
)
limit = limit if _whole_line_number(limit) else None
yield sse_event(
ChatToolEvent(name=piece.name, argument=argument).model_dump()
ChatToolEvent(
name=piece.name,
argument=argument,
offset=offset,
limit=limit,
).model_dump()
)
continue
if isinstance(piece, RetryPiece):
+345 -70
View File
@@ -94,10 +94,28 @@ task 04):
permission 2026-09-10, ``TODO.md`` L5): the first ``read_max_chars``
chars plus the shared :data:`TRUNCATION_MARKER` and the pinned
:data:`READ_TRUNCATION_NOTICE` (the rest is NOT in the model's
context; ``grep`` — which searches the whole document — is the
follow-up), with the truncation recorded on the holder so the loop
context; the notice teaches the phase-131 workflow — ``grep``
reports line numbers, then ``read`` that part with ``offset`` /
``limit``), with the truncation recorded on the holder so the loop
yields a :class:`app.rag.llm.ToolResultPiece` (task 02 → the SSE
``tool_result`` frame + UI marker). **A6 re-revised contract
``tool_result`` frame + UI marker). Phase 131 (task 02): ``read``
gains the OPTIONAL line range — ``offset`` (1-based first line) +
``limit`` (line count; ``offset`` alone reads to the end, ``limit``
alone counts from line 1 — :func:`_read_range_args` validates
whole ints ≥ 1, a bad value is the :data:`BAD_RANGE_ARGS` refusal
with the values echoed): a range read returns EXACTLY the named
lines under a third ``lines: {offset}-{end} of {total_lines}``
header line (the true extent — an overshooting ``limit`` clamps to
the last line), the cap applies to the SLICE (the holder tuple's
``chars_total`` is then the slice's length, not the document's),
and an ``offset`` past the end is the :data:`OFFSET_PAST_END`
teaching. The dedupe matrix opens the ONE previously-dead case — a
range read of a document whose earlier read was TRUNCATED (the
stuck scenario: the range the model names is exactly the part it
does NOT have); a PLAIN re-read of a truncated document gets the
:data:`REREAD_TRUNCATED` teaching instead of the (wrong) full-text
claim, and every other re-read of a fully-read document stays
:data:`ALREADY_IN_CONTEXT`. **A6 re-revised contract
(phase 118, owner directive 2026-09-15):** the retrieval
``<documents>`` path seeds SUMMARIES only — a suggested document's
full text never enters the prompt on the retrieval path; full text
@@ -145,7 +163,15 @@ task 04):
:data:`ALREADY_IN_CONTEXT` (phase 72, task 05 gate iteration:
the line names the correct action — answer from the text already
in the prompt, do not call read again — so a fired refusal ends
the loop instead of inviting a repeat); an unknown document (a ``read`` or scoped ``grep`` whose
the loop instead of inviting a repeat); a ``read`` whose optional
``offset``/``limit`` is not a whole line number ≥ 1 (phase 131) →
:data:`BAD_RANGE_ARGS` (the bad values echoed); a range ``read``
whose ``offset`` is past the document's end (phase 131) →
:data:`OFFSET_PAST_END` (the true line count named); a PLAIN
re-read of a document whose earlier read was TRUNCATED (phase 131)
→ :data:`REREAD_TRUNCATED` (the grep → read(offset/limit) teaching
— the full text is NOT in the prompt, unlike the line above); an
unknown document (a ``read`` or scoped ``grep`` whose
combined ``source/path`` matches nothing — a bare source name, which
can never be a document, included) → ``"No document at '…' — check
the ls output."`` with the argument echoed as passed (the model sees
@@ -313,12 +339,15 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"document you have already read — its full text is "
"already in your prompt. Very large documents are "
"truncated: you receive the first part plus a TRUNCATED "
"notice naming how many more characters exist — the "
"notice is authoritative, the document did NOT end "
"where it stopped. Follow it and use `grep` (pattern) "
"to locate the rest — it searches the whole document. "
"Call one tool at a time — wait for this result before "
"your next call."
"notice naming how many more characters exist. To read "
"the rest: use `grep` (pattern) to find where your "
"text is — it reports line numbers — then call `read` "
"again on the same document with `offset` (1-based "
"first line) and `limit` (number of lines) to read "
"exactly that part. Do not re-read a document you have "
"already read in full — its full text is already in "
"your prompt. Call one tool at a time — wait for this "
"result before your next call."
),
"parameters": {
"type": "object",
@@ -335,7 +364,27 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"document you have already read — its full "
"text is already in your prompt."
),
}
},
"offset": {
"type": "integer",
"description": (
"The first line to read, 1-based. Omit it to "
"read from the beginning of the document. "
"Use it to read a specific part of a large "
"document — grep reports the line numbers "
"of what you found."
),
},
"limit": {
"type": "integer",
"description": (
"The maximum number of lines to read, "
"starting at offset. Omit it to read as "
"many lines as fit under the character "
"cap. Keep it small — you are reading a "
"part, not the document."
),
},
},
"required": ["path"],
},
@@ -349,16 +398,17 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"Search the indexed documents for an exact string "
"(case-insensitive) and return up to 20 matching lines "
"as `source/path:line: text` — a locator, not a "
"context-adder: read the winner with `read`. The "
"pattern is a plain substring, NEVER a regex — if a "
"pattern with regex syntax (like '.*' or '\\.') comes "
"back with no matches, retry with the plain text you "
"expect to see. For a normal search pass ONLY `pattern` "
"— it searches every document and that is how you "
"search the knowledge base; never pass a source name "
"as `path` (a source name is not a document). Call one "
"tool at a time — wait for this result before your "
"next call."
"context-adder: read the winner with `read` — for a "
"large document, use the reported line number as "
"`offset`. The pattern is a plain substring, NEVER a "
"regex — if a pattern with regex syntax (like '.*' "
"or '\\.') comes back with no matches, retry with "
"the plain text you expect to see. For a normal "
"search pass ONLY `pattern` — it searches every "
"document and that is how you search the knowledge "
"base; never pass a source name as `path` (a source "
"name is not a document). Call one tool at a time — "
"wait for this result before your next call."
),
"parameters": {
"type": "object",
@@ -491,23 +541,82 @@ ALREADY_SHOWING = (
"Answer from what you have."
)
#: The read-truncation notice (phase 95, task 01) — appended, after the
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
#: is LONGER than ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``,
#: default 128 000 chars ≈ 32k tokens). Two format fields: ``{total}``
#: (the document's true character count) and ``{shown}`` (the cap — how
#: many characters actually reached the model). The copy is pinned: it
#: tells the model the read was truncated, that the rest is NOT in its
#: context (the document did not end where it stopped), and names
#: ``grep`` — the phase-70 harness-aligned locator that searches the
#: WHOLE document — as the tool to find what it was looking for. A read
#: at or under the cap appends nothing (byte-identical to the
#: pre-phase-95 result).
#: The read-truncation notice (phase 95, task 01; REWRITTEN phase 131,
#: task 01) — appended, after the shared :data:`TRUNCATION_MARKER`, to
#: a ``read`` result whose document is LONGER than
#: ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default
#: 128 000 chars ≈ 32k tokens). Two format fields: ``{total}`` (the
#: document's true character count) and ``{shown}`` (the cap — how many
#: characters actually reached the model). The copy is pinned: it tells
#: the model the read was truncated and that the rest is NOT in its
#: context (the document did not end where it stopped), then teaches
#: the phase-131 workflow — the old dead-end ("use grep to locate the
#: rest", with no way to act on the line number) is gone: ``grep``
#: reports LINE NUMBERS, and the model reads the part it needs with
#: ``read``'s ``offset`` (1-based first line) + ``limit`` (number of
#: lines). A read at or under the cap appends nothing (byte-identical
#: to the pre-phase-95 result).
READ_TRUNCATION_NOTICE = (
"TRUNCATED — this document is {total} characters; only the first "
"{shown} are in your context. The rest is NOT shown. Use grep "
"(pattern) to locate what you need — grep searches the whole "
"document."
"(pattern) to find what you need — it reports line numbers — "
"then read that part with `offset` (1-based first line) and "
"`limit` (number of lines)."
)
#: The ``read`` range-argument validation refusal (phase 131, task
#: 02): ``offset`` / ``limit``, when present, must each be a whole
#: line number ≥ 1 — an ``int`` (bools excluded — the house
#: ``isinstance(x, int) and not isinstance(x, bool)`` guard) and not
#: ``< 1``. The bad values are echoed (``{offset!r}`` /
#: ``{limit!r}`` — an absent value prints as ``None``), so the model
#: sees exactly what it sent. Absent/``None`` for BOTH is fine (a
#: plain read); ``offset`` without ``limit`` reads to the END of the
#: document; ``limit`` without ``offset`` is treated as ``offset=1``
#: (a line count from the start) — the two one-sided semantics
#: :func:`_read_range_args` validates and the read branch applies.
#: Still a refusal: counts in nothing, consumes a round (the phase-72
#: style — no silent argument repair), and it fires BEFORE the dedupe
#: and the DB resolve (a bad range argument is refused regardless of
#: what is in context).
BAD_RANGE_ARGS = (
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
"(you passed offset={offset!r}, limit={limit!r})."
)
#: The plain re-read of a TRUNCATED document (phase 131, task 02 — the
#: dedupe matrix's middle row): the model already holds the first
#: ``{shown}`` characters (the cap from the matching
#: ``holder.read_truncations`` tuple — the most recent truncation of
#: the pair, plain or range) and its first read of the document hit
#: the cap, so the rest is NOT in its context. The line points at the
#: working workflow — ``grep`` reports line numbers, then ``read``
#: that part with ``offset`` / ``limit`` — instead of the
#: pre-phase-131 dead end (the plain :data:`ALREADY_IN_CONTEXT` line,
#: which claimed the FULL text was in the prompt). ``{source}`` /
#: ``{path}`` are the argument's split pair; ``{shown}`` is a plain
#: integer. A refusal: no re-emit, ``tool_calls`` NOT incremented,
#: consumes a round, fires on the raw-arg fast path (no DB lookup).
REREAD_TRUNCATED = (
"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) and `limit` (lines)."
)
#: The range-``read`` offset past the end (phase 131, task 02): the
#: document is ``{total_lines}`` lines long (``len(content.
#: splitlines())``) and the ``offset`` names a line beyond it.
#: ``{source}`` / ``{path}`` are the RESOLVED identity; ``{total_lines}``
#: and ``{offset}`` are plain integers (no ``!r``). The pointer — grep
#: reports the exact line numbers — hands the model back to the
#: locator that got it here. A refusal: counts in nothing, consumes a
#: round; the document is NOT added to the context, so a corrected
#: retry (a smaller offset) still proceeds.
OFFSET_PAST_END = (
"{source}/{path} is {total_lines} lines long — `offset` {offset} "
"is past the end. Use a smaller offset (grep reports the exact "
"line numbers)."
)
#: The image-document marker (phase 122, task 05): the line prefixed to
@@ -1208,6 +1317,48 @@ def _no_document_refusal(db: Session, arg: str) -> str:
return f"No document at '{arg}' — check the ls output."
def _read_range_args(
offset: Any, limit: Any
) -> tuple[bool, str, int | None, int | None]:
"""Validate ``read``'s optional line range (phase 131, task 02).
Returns ``(ok, error_line, offset, limit)``:
* both absent/``None`` → ``(True, "", None, None)`` — a plain read
(no range): from the beginning, capped as pre-phase-131.
* either PRESENT but not a whole int ≥ 1 (not an ``int`` — bools
excluded — or ``< 1``) → ``(False, BAD_RANGE_ARGS.format(...),
None, None)`` — the values echoed with ``!r`` (an absent one
prints ``None``). A refusal: counts in nothing, consumes a
round; it fires before the dedupe and the DB resolve.
* ok → ``(True, "", offset, limit)`` — either may be ``None``:
``offset`` without ``limit`` reads to the END of the document,
``limit`` without ``offset`` is treated as ``offset=1`` (a line
count from the start) — the caller applies both one-sided
semantics.
Pure (no I/O) — unit tests drive it directly.
"""
def _whole_line_number(value: Any) -> bool:
return (
isinstance(value, int) and not isinstance(value, bool) and value >= 1
)
if offset is None and limit is None:
return True, "", None, None
if (offset is not None and not _whole_line_number(offset)) or (
limit is not None and not _whole_line_number(limit)
):
return (
False,
BAD_RANGE_ARGS.format(offset=offset, limit=limit),
None,
None,
)
return True, "", offset, limit
def grep_document(content: str, pattern: str) -> list[tuple[int, str]]:
"""Every line of *content* that contains *pattern*, in file order.
@@ -1243,7 +1394,9 @@ class AgentHolder:
chars_total)`` tuple per TRUNCATED ``read`` — the combined
``source/path`` the model passed, the cap kept
(``settings.read_max_chars``), and the document's true length — in
execution order. Recorded on truncation only; a read at or under the
execution order. For a TRUNCATED RANGE read (phase 131, task 02)
``chars_total`` is the SLICE's length (the requested size of the
part that was read), not the document's. Recorded on truncation only; a read at or under the
cap appends nothing. A truncated read is still a **successful**
call: ``tool_calls`` increments as today and ``read_docs`` appends
as today — this list only carries the truncation signal the agent
@@ -1285,7 +1438,19 @@ def _execute_tool(
(the rest of the document is NOT in the model's context), and a
``(argument, cap, total)`` tuple is appended to
``holder.read_truncations`` (the signal the agent loop turns into a
:class:`app.rag.llm.ToolResultPiece`). Document targets are combined ``source/path``
:class:`app.rag.llm.ToolResultPiece`). Phase 131 (task 02): a
``read`` may carry the optional line range ``offset`` (1-based
first line) + ``limit`` (line count) — validated by
:func:`_read_range_args` (a bad value → :data:`BAD_RANGE_ARGS`,
before the dedupe and the resolve), the dedupe matrix (a range
read of a TRUNCATED document proceeds — the stuck-scenario fix —
a plain re-read of a truncated one gets :data:`REREAD_TRUNCATED`,
every other re-read of a fully-read one stays
:data:`ALREADY_IN_CONTEXT`), an ``offset`` past the end →
:data:`OFFSET_PAST_END`, the slice shaped under a ``lines:
{offset}-{end} of {total_lines}`` header line and capped like the
whole document (the tuple's ``chars_total`` is then the slice's
length). Document targets are combined ``source/path``
strings, resolved by :func:`_resolve_path` (the canonical identity,
phase 70).
"""
@@ -1350,18 +1515,57 @@ def _execute_tool(
arg = raw_path.strip() if isinstance(raw_path, str) else ""
if not arg:
return MISSING_READ_ARGS
# Phase 131 (task 02): the OPTIONAL line range — validated
# after the path check and BEFORE the dedupe: a bad range
# argument is a refusal regardless of what is in context
# (counts in nothing, consumes a round — the phase-72 style;
# no silent argument repair, no DB lookup).
ok, range_error, offset, limit = _read_range_args(
call.arguments.get("offset"), call.arguments.get("limit")
)
if not ok:
return range_error
# A one-sided range: ``limit`` without ``offset`` counts from
# the start of the document (first line 1 — the helper's
# documented semantics); ``offset`` without ``limit`` reads to
# the end (the slice math below).
ranged = offset is not None or limit is not None
first = offset if offset is not None else 1
# Phase 118 (A6): the dedupe set is ``holder.read_docs`` ONLY —
# the ``seed_docs`` are SUMMARY blocks in the prompt, not full
# text, so a FIRST read of a suggested document adds its full
# text through the path below; only a document ALREADY READ is
# refused.
known = {(doc.source, doc.path) for doc in holder.read_docs}
# The dedupe check needs no DB: the split pair of a combined
# identity that is already in full-text context is in `known`
# as-is (the resolve below would find the same document).
if "/" in arg:
# Phase 131 (task 02): the TRUNCATED set, keyed on the
# RESOLVED (source, path) pair — the holder tuples carry the
# raw argument the model passed, and the same first-slash
# split the resolve below performs recovers the pair. A pair
# keeps the MOST RECENT truncation's shown count (a plain
# truncation followed by a truncated range read → the range
# read's).
truncated_shown: dict[tuple[str, str], int] = {}
for argument, shown, _total in holder.read_truncations:
a = argument.strip()
if "/" in a:
t_source, _, t_path = a.partition("/")
truncated_shown[(t_source, t_path)] = shown
# The raw-arg fast path handles the "plain read + already in
# context" rows with no DB lookup (the pre-phase-131 check —
# the split pair of a combined identity that is in
# full-text context is in `known` as-is, the resolve below
# would find the same document — now split on the truncated
# set): truncated earlier → the :data:`REREAD_TRUNCATED`
# teaching (the rest is NOT in the prompt — point at the
# grep → read(offset/limit) workflow); never truncated →
# :data:`ALREADY_IN_CONTEXT` (byte-identical to pre-phase-131).
if not ranged and "/" in arg:
src, _, p = arg.partition("/")
if (src, p) in known:
if (src, p) in truncated_shown:
return REREAD_TRUNCATED.format(
shown=truncated_shown[(src, p)], source=src, path=p
)
return ALREADY_IN_CONTEXT
doc, _source, _path = _resolve_path(db, arg)
if doc is None:
@@ -1370,7 +1574,40 @@ def _execute_tool(
# a path-like argument that matches an indexed document's
# path gets the "did you mean …?" teaching (phase 72).
return _no_document_refusal(db, arg)
holder.read_docs.append(doc)
# Range read + already in context (phase 131, task 02 — the
# dedupe matrix's range rows; the truncated-set check needs
# the RESOLVED pair, so it runs here, after the resolve, where
# ``doc`` is known): the truncated-then-range case PROCEEDS
# (the stuck-scenario fix — the range the model names is
# exactly the part it does NOT have); a range of a fully-read
# document is :data:`ALREADY_IN_CONTEXT` (the range is a
# subset of the text already in the prompt).
if (
ranged
and (doc.source, doc.path) in known
and (doc.source, doc.path) not in truncated_shown
):
return ALREADY_IN_CONTEXT
lines = doc.content.splitlines()
total_lines = len(lines)
if ranged and first > total_lines:
# The offset names a line past the end — teaching refusal:
# the true line count named, grep reports the exact line
# numbers (the locator that got the model here). Counts in
# nothing, consumes a round; the document is NOT added to
# the context, so a corrected retry still proceeds.
return OFFSET_PAST_END.format(
source=doc.source,
path=doc.path,
total_lines=total_lines,
offset=first,
)
# The checks above refused every duplicate that must be
# refused — append at most ONCE: a first read (plain or range)
# appends; the range read of an earlier TRUNCATED read of the
# same document (the row that proceeded) does not re-append.
if (doc.source, doc.path) not in known:
holder.read_docs.append(doc)
holder.tool_calls += 1
# Phase 122 (task 05): an image document's content IS the vision
# description — the marker line (a third line between the
@@ -1379,43 +1616,81 @@ def _execute_tool(
# stays byte-identical to pre-122.
marker = f"{IMAGE_DOC_MARKER}\n" if doc.is_image else ""
cap = settings.read_max_chars
if len(doc.content) > cap:
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
# read cap — cut at the cap and tell the model the truth: the
# shared marker + the pinned notice name how much more exists
# and point at ``grep`` (which searches the WHOLE document).
# A truncated read is still a successful call (both counters
# above already bumped); the holder tuple is the truncation
# signal only, so the loop can yield a ToolResultPiece.
# ``raw_path`` (a str here — ``arg`` is non-empty only when it
# was) is the exact argument the matching SSE ``tool`` frame
# carries, so the UI can match the two (task 02); the cast
# keeps pyright honest about the holder tuple's first element.
holder.read_truncations.append(
(cast("str", raw_path), cap, len(doc.content))
)
# Phase 106 (D5): the date rides every document the model
# sees — the ``read`` result's SECOND line; the FIRST line
# stays ``Document {source}/{path}:`` BYTE-IDENTICAL (the
# E2E mock's ``_READ_RESULT_PREFIX`` header contract).
if not ranged:
# Plain read — the pre-phase-131 bytes, untouched.
if len(doc.content) > cap:
# Phase 95 (owner permission 2026-09-10, ``TODO.md``
# L5): the read cap — cut at the cap and tell the model
# the truth: the shared marker + the pinned notice name
# how much more exists and teach the grep →
# read(offset/limit) workflow (phase 131 — the notice
# itself names the follow-up call). A truncated read is
# still a successful call (both counters above already
# bumped); the holder tuple is the truncation signal
# only, so the loop can yield a ToolResultPiece.
# ``raw_path`` (a str here — ``arg`` is non-empty only
# when it was) is the exact argument the matching SSE
# ``tool`` frame carries, so the UI can match the two
# (task 02); the cast keeps pyright honest about the
# holder tuple's first element.
holder.read_truncations.append(
(cast("str", raw_path), cap, len(doc.content))
)
# Phase 106 (D5): the date rides every document the
# model sees — the ``read`` result's SECOND line; the
# FIRST line stays ``Document {source}/{path}:``
# BYTE-IDENTICAL (the E2E mock's ``_READ_RESULT_PREFIX``
# header contract).
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{marker}"
f"{doc.content[:cap]}\n"
f"{TRUNCATION_MARKER}\n"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
)
# At or under the cap: the pre-phase-95 result plus the
# phase-106 D5 date line (first line byte-identical — the
# mock's header contract; no truncation marker, no notice,
# no holder entry, no ToolResultPiece) — and, phase 122,
# the image-document marker line for image docs only.
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{marker}"
f"{doc.content[:cap]}\n"
f"{doc.content}"
)
# Range read (phase 131, task 02): EXACTLY the named lines —
# the TRUE extent on the third line (``end`` clamps to the
# last line: an overshooting ``limit`` reads to the end, never
# past it; ``offset`` without ``limit`` reads to the end), the
# image-doc marker (when one) exactly where the plain read
# puts it (after the header lines), the cap applied to the
# SLICE.
end = total_lines if limit is None else min(first + limit - 1, total_lines)
slice_text = "\n".join(lines[first - 1 : end])
if len(slice_text) > cap:
# The cap on the slice: the same marker + the pinned notice
# (``{total}`` = the slice's length — how much of THIS read
# was cut); the holder tuple's ``chars_total`` is the
# slice's length, NOT the document's (documented on
# :class:`AgentHolder.read_truncations`).
holder.read_truncations.append(
(cast("str", raw_path), cap, len(slice_text))
)
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"lines: {first}-{end} of {total_lines}\n"
f"{marker}{slice_text[:cap]}\n"
f"{TRUNCATION_MARKER}\n"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))}"
)
# At or under the cap: the pre-phase-95 result plus the
# phase-106 D5 date line (first line byte-identical — the
# mock's header contract; no truncation marker, no notice, no
# holder entry, no ToolResultPiece) — and, phase 122, the
# image-document marker line for image docs only.
return (
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{marker}"
f"{doc.content}"
f"lines: {first}-{end} of {total_lines}\n"
f"{marker}{slice_text}"
)
if call.name == "show_image":
# Phase 124 (task 02; LOCKED A3/A5): the 4th agent tool — the
+28
View File
@@ -197,11 +197,39 @@ class ChatToolEvent(BaseModel):
``done`` shapes are unchanged — the read document is reflected in
``done.sources`` instead (a grep adds no source: it is a locator,
locked A5).
Phase 131 (task 04): ``offset`` / ``limit`` — the LINE RANGE a
``read`` call requested (the optional 1-based first line + line
count, PLAN §6's phase-131 revision). Additive in the
:class:`ChatDoneEvent.answer_image` style: absent (``None``) on
every non-range call — ``ls`` / ``grep`` / plain ``read``, and
every pre-phase frame — where BOTH keys are DROPPED on
serialization (ABSENT, never ``null``), so those frames stay
byte-identical to pre-phase (the story E2E suites pin them as
exact dicts). Only a valid range read carries them (a bad range
value is a refused call — the refusal surfaces as the round's
result, and the frame carries no range). The client ignores the
extra fields (PLAN §4); they exist so the wire — and the phase-131
story E2E — can assert the range the model requested.
"""
type: Literal["tool"] = "tool"
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names)
argument: str | None = None # the single string argument passed, or null
offset: int | None = None # read only: the 1-based first line requested
limit: int | None = None # read only: the line count requested
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
data = handler(self)
# Absent range → ABSENT keys (never null): the non-range frame
# stays byte-identical to pre-phase-131 (the omission pattern
# of SourceRef.image_url / ChatDoneEvent.answer_image).
if self.offset is None:
data.pop("offset", None)
if self.limit is None:
data.pop("limit", None)
return data
class ChatToolResultEvent(BaseModel):
+239 -2
View File
@@ -539,6 +539,50 @@ Implements just enough of the aipi surface:
question always gets the echo whatever the honesty gate says.
Verified 2026-09-27: no existing E2E question or fixture file
contains the phrase, so every other suite is unaffected.
- user message containing ``read the lines``
(``READ_LINES_TRIGGER``, phase 131 task 04 — the read
line-ranges' dedicated story suite
``tests/e2e/test_read_line_ranges.py``) **and** the system
prompt carries the ``<tools>`` section -> the deterministic
grep -> read(offset/limit) DANCE (the owner's stuck scenario,
TODO.md L7, end to end): classified statelessly by the tool
results in the messages (the house count pattern — streaming
only):
* request 1 (``tools`` offered, no tool results yet): stream
ONLY ``tool_calls`` deltas — ``grep`` with
``{"pattern": READ_LINES_PATTERN}`` (id ``call_0``), no
content, no reasoning;
* a ``tool``-role grep result with a ``source/path:line: text``
match line is in the messages (the FIRST match line's line
number is parsed): the RANGE READ — a ``tool_calls`` delta —
``read`` on the match line's combined ``source/path`` with
``offset`` = the parsed line number (an int) and ``limit`` =
``READ_LINES_LIMIT`` (30) (id ``call_1``) — the owner's exact
dance: the range the model names is the part it does NOT
have;
* a ``tool``-role read result (content starting with the
agent's ``"Document "`` prefix) is in the messages: the
forced answer, byte-stable: ``Found it at line N. <first 80
chars of the read result's content>`` — N = the offset the
model passed (parsed from the read call's arguments in the
assistant tool-call message — the agent loop echoes every
executed call back into the messages), the quote = the read
slice with the header lines (the phase-106 D5 ``date:`` line
+ the phase-131 ``lines: a-b of N`` extent) skipped, so the
quote is the pure range content (the phase-94 read-echo
convention — the RANGE reached the model's context);
* a tool result is in the messages that is neither a read
result nor a grep match (the grep found nothing — the
premise broke): the deterministic terminal answer ``No
matches — the knowledge base has no such text.`` (the
grep-teach ``nomatch`` convention — the suite fails loudly
on the missing marker).
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint
trigger phrases — the phase-71/72/94/118 ordering convention;
the trigger needs the ``<tools>`` section, so deflected turns
never hit it); verified 2026-09-28: no existing E2E question or
fixture file contains the phrase, so every other suite is
unaffected.
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
@@ -638,6 +682,7 @@ test_llm_retry.py``). The mock is single-conversation per e2e server, so
from __future__ import annotations
import hashlib
import json
import math
import os
import re
@@ -2020,6 +2065,144 @@ def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("echo", results[-1])
# ---------------------------------------------------------------------------
# Phase 131 (task 04, the read line-ranges' dedicated story suite):
# the deterministic grep -> read(offset/limit) dance — see the module
# docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt — drives
#: the deterministic RANGE-READ flow (the read line-ranges' story
#: suite, ``tests/e2e/test_read_line_ranges.py``): the owner's stuck
#: scenario (TODO.md L7) — the huge document: ``grep`` finds the
#: line, the ``read`` lands exactly that part with ``offset`` /
#: ``limit``, and the answer echoes the read slice. Checked BEFORE
#: the plain ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — the
#: phase-71/72/94/118 ordering convention); verified 2026-09-28: no
#: existing E2E question or fixture file contains the phrase, so
#: every other suite is unaffected.
READ_LINES_TRIGGER = "read the lines"
#: The canned pattern the flow greps for (the suite's fixture document
#: carries exactly one line containing it — the flow's read target
#: line; the sentinel convention of ``SEARCH_PATTERN``).
READ_LINES_PATTERN = "LINE_MARKER"
#: The line count the flow's range read requests (a PART of the
#: document, not the whole — the owner's dance, TODO.md L7).
READ_LINES_LIMIT = 30
def _read_call_offset(body: dict[str, Any]) -> int:
"""The offset the model's ``read`` call passed (phase 131, task 04).
The agent loop echoes every executed call back into the messages
(the assistant ``tool_calls`` message, the arguments as a JSON
string — ``app/rag/agent.py``), so the range-read request
(request 3 of the flow) carries the call it was made from: this
parses the FIRST assistant ``read`` call's ``offset``. ``0`` when
absent or unparseable (the premise broke — the suite fails
loudly on ``line 0``).
"""
for m in _messages(body):
if m.get("role") != "assistant":
continue
# The agent loop's echoed call (the ``tool_calls`` LIST on the
# assistant message — ``app/rag/agent.py``; :func:`_messages`
# is typed for string content, so the list comes back as
# ``Any`` here — the house mock convention over the wire
# shape).
raw_calls: Any = m.get("tool_calls")
for call in (raw_calls if isinstance(raw_calls, list) else []):
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
if not isinstance(fn, dict) or fn.get("name") != "read":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
continue
offset = args.get("offset") if isinstance(args, dict) else None
if isinstance(offset, int) and not isinstance(offset, bool):
return offset
return 0
def _read_lines_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-131 range-read request (see the module
docstring). Classified statelessly by the tool results in the
messages (the house count pattern), like the other marker flows:
* ``("answer", offset, quote)`` — a ``tool``-role read result
(content starting with the agent's ``"Document "`` prefix) is in
the messages: the forced answer, byte-stable — ``Found it at
line N. <quote>`` with N the offset the model passed (parsed
from the read call's arguments in the assistant tool-call
message — a string on this return for the homogeneous
``tuple[str, ...]`` shape; the dispatch formats it into the
answer, the display surface) and quote the read result's
content with the header lines (the phase-106 D5 ``date:`` line
+ the phase-131 ``lines: a-b of N`` extent) skipped — the pure
read slice, first 80 chars (the phase-94 read-echo convention:
the RANGE reached the model's context).
* ``("read", combined, line, "call_1")`` — a ``tool``-role grep
result carrying ``source/path:line: text`` match lines is in the
messages: the RANGE READ on the FIRST match line's combined
``source/path`` — ``offset`` = the parsed line number (the
grep's own string here — the dispatch parses the int for the
wire) and ``limit`` = :data:`READ_LINES_LIMIT`.
* ``("nomatch",)`` — a tool result is in the messages but it is
neither a read result nor a grep match (the grep found nothing
— the premise broke): the deterministic terminal answer (the
grep-teach ``nomatch`` line — the suite fails loudly on the
missing marker).
* ``("grep",)`` — ``tools`` are offered and no tool results are in
the messages yet: the first step — ``grep`` with
``{"pattern": READ_LINES_PATTERN}`` (id ``call_0``).
* ``None`` — not the flow: the trigger is absent, the ``<tools>``
section is missing (deflected turns never carry it), or
``tools`` are not offered and no tool results are in the
messages yet (e.g. ``agent_max_rounds=0``).
"""
if READ_LINES_TRIGGER not in _user(body).lower():
return None
if "<tools>" not in _system(body):
return None
reads = _read_results(body)
if reads:
_sp, content = reads[-1]
# The read result's header lines sit between the ``Document
# …:`` header and the document text — the phase-106 D5
# ``date:`` line always, the phase-131 ``lines: a-b of N``
# extent on a range read (a plain read carries no extent
# line) — skipped (the drill-flow convention) so the quote is
# the pure read content (the suite's marker-line assertion).
if content.startswith("date: "):
content = content.partition("\n")[2]
if content.startswith("lines: "):
content = content.partition("\n")[2]
return ("answer", str(_read_call_offset(body)), content[:80])
for result in _tool_results(body):
if result.startswith(_READ_RESULT_PREFIX):
continue
for line in result.splitlines():
match = _SEARCH_LINE_RE.match(line)
if match:
return (
"read",
match.group("sp"),
match.group("line"),
"call_1",
)
if _tool_results(body):
return ("nomatch",)
if not body.get("tools"):
return None
return ("grep",)
def long_answer() -> str:
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
a unique final line that must survive the stream untruncated."""
@@ -2844,8 +3027,6 @@ def _sse_stream(
def json_dumps(obj: dict[str, Any]) -> str:
import json
return json.dumps(obj)
@@ -3186,6 +3367,62 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 131 (task 04): the deterministic grep ->
# read(offset/limit) DANCE (the read line-ranges' story suite,
# tests/e2e/test_read_line_ranges.py — the owner's stuck
# scenario, TODO.md L7): grep finds the line, the range read
# lands the part the model does NOT have, the answer echoes
# the read slice. Checked BEFORE the plain TOOLS_TRIGGER flow
# (disjoint trigger phrases — the phase-71/72/94/118 ordering
# convention; the trigger needs the ``<tools>`` section, so
# deflected turns never hit it).
read_lines = _read_lines_flow(body)
if read_lines is not None:
if read_lines[0] == "grep":
stream = _tool_call_stream(
"grep", {"pattern": READ_LINES_PATTERN}, "call_0"
)
elif read_lines[0] == "read":
# The owner's dance, deterministic: ``offset`` = the
# grep-reported line number (parsed from the FIRST
# match line — the classifier keeps it as the grep's
# own string for the homogeneous return; the int
# here is the wire's), ``limit`` = READ_LINES_LIMIT
# (a part of the document, not the whole).
stream = _tool_call_stream(
"read",
{
"path": read_lines[1],
"offset": int(read_lines[2]),
"limit": READ_LINES_LIMIT,
},
read_lines[3],
)
elif read_lines[0] == "nomatch":
# The premise broke (the grep found nothing) — the
# deterministic terminal answer (the grep-teach
# ``nomatch`` convention — the suite fails loudly on
# the missing marker).
stream = _sse_stream(
_apply_max_tokens(
"No matches — the knowledge base has no such text.",
body.get("max_tokens"),
),
0.0,
)
else: # "answer" — the forced read-echo (phase-94 shape)
stream = _sse_stream(
_apply_max_tokens(
f"Found it at line {read_lines[1]}. {read_lines[2]}",
body.get("max_tokens"),
),
0.0,
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 124 (task 07, image chips and answers, TODO L3): the
# scripted SHOW_IMAGE flow — the mock shows the seeded image
# document (the ``<documents>`` block with the image-
+655
View File
@@ -0,0 +1,655 @@
"""Phase 131 task 04 E2E (Playwright, mock-only): the read line-ranges'
dedicated story suite — the owner's stuck scenario, end to end.
The whole TODO.md L7 item (``131_read_line_ranges`` task 04 is the
story's gate): with the owner's ``BOR_READ_MAX_CHARS=24000`` in force,
the agent deterministically performs the grep → read(offset/limit)
dance on a ~100 KB document — the grep finds the marker line and
reports its line number, the range read lands exactly that part (the
part the model did NOT have), the SSE ``tool`` frames carry the REAL
``offset``/``limit`` ints, and the final answer is grounded in the
document's tail (the marker line). The second turn pins the other half
of the fix: a plain read of the same large document truncates at
24000, and the surfaced truncation carries the REWRITTEN notice that
teaches the grep → read(offset/limit) workflow (the pre-phase dead-end
wording is gone).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real
``turbo`` does whatever it does with the tools, while this story's
gate is the deterministic SCRIPTED flows in
``tests/e2e/mock_llm.py`` (the mock is the only E2E lens on the
LLM's context):
* the range turn — ``READ_LINES_TRIGGER``: a user message containing
``read the lines`` **and** the system prompt carrying the HIGH
prompt's ``<tools>`` section. Request 1 greps for
``READ_LINES_PATTERN``; request 2 (a ``tool``-role grep result with
a ``source/path:line:`` match line) reads the match line's document
with ``offset`` = the parsed line number and ``limit`` =
``READ_LINES_LIMIT`` (30); request 3 (the read result present)
answers ``Found it at line N. <first 80 chars of the read slice>``
— N the offset the model passed (the phase-94 read-echo
convention: the RANGE reached the context).
* the plain-read turn — the mock's EXISTING ``READ_CAP_TRIGGER`` flow
(``read the capped document: read source/path``, the
``test_read_truncation_cap.py`` convention) reused on THIS suite's
large document: the mock ECHOES the ENTIRE read tool result into
its final answer (the house scripted-turn way of asserting on tool
results), so the truncated shape — first cap chars +
``[…truncated…]`` + the REWRITTEN notice — lands on the rendered
answer and the suite asserts the notice's presence AND the old
dead-end's absence through it.
The app under test boots with the OWNER'S cap (the
env-override-for-the-app-under-test pattern — this suite's module app,
as in ``test_read_truncation_cap.py``): ``BOR_READ_MAX_CHARS=24000``
(the owner's own setting, TODO.md L7) — small enough that the ~99 KB
fixture document's plain read truncates deterministically at 24000,
big enough that the 30-line range slice (~1 300 chars) never does.
KB fixture — ONE large deterministic markdown document
(``linedoc/big.md``, ~99 KB): this module generates it — 2 000
numbered filler lines (``Line <i>: …`` — the line numbers are exact)
under 200 ``##`` headings (one heading per 10 lines), with the marker
line ``LINE_MARKER the trap components are documented here`` at the
1 500th numbered line (= line 1 652 of the document — a known line
near ~1500, FAR beyond the 24 000-char cap: the document's first
24 000 chars hold NO marker). The marker's true line is computed from
the very string written to the fixture (``BIG_DOC.splitlines().index(
…) + 1``) — the assertions use that value, never a literal, so the
suite stays true if the fixture is regenerated.
The app under test is mock-calibrated (conftest pattern):
``BOR_RELEVANCE_THRESHOLD=0.30`` + ``BOR_LEXICAL_SUPPORT_FLOOR=0.15``
+ ``BOR_SOURCE_USEFULNESS_FLOOR=0.15`` — both questions' best-chunk
cosine (~0.23 on the mock's token-overlap distribution, the marker
chunk wins) clears the floor with FTS hits (``trap`` / ``components``
in the marker chunk), so every scripted turn stays GROUNDED (the
``<tools>`` section is present), and the read document clears the
usefulness bar into ``done.sources``.
Test → observable mapping (Playwright Mapping Rule; the story is the
owner's TODO item — one Playwright file per story, A16):
1. ``test_range_read_flow_over_sse`` — a question containing
``read the lines`` → the SSE ``tool`` frames, IN ORDER: the
``grep`` call (arg ``pattern="LINE_MARKER"``), then the ``read``
call (asserted on the FRAME's args, not a substring: ``path`` =
the doc's combined ``source/path``, ``offset`` an int == the
marker's true line, ``limit`` == 30 — the wire's assertion
surface for the owner's dance); the ``done`` frame is
non-deflected with the read document cited (phase 119 A1); the
final answer is the GROUNDED echo — ``Found it at line <N>``
(N == the marker's true line) + the marker text itself (the range
reached the model's context); NO truncation-notice dead-end
(neither the ``[…truncated…]`` marker nor the notice is in the
answer — the range read did not truncate, so no ``tool_result``
frame either).
2. ``test_plain_read_truncation_notice_teaches_the_workflow`` — a
question carrying the mock's existing plain-read trigger (``read
the capped document: read linedoc/big.md`` — the
``READ_CAP_TRIGGER`` flow on this suite's large document) → the
plain read truncates at 24 000: the SSE ``tool_result`` frame
(``truncated: true``, ``chars_shown: 24000``, ``chars_total:`` the
document's true length) lands after the ``tool`` frame and before
the answer's first delta (the pinned order); the surfaced
truncation — the mock's echo of the read result, the LLM-side
surface the notice rides on — carries the REWRITTEN notice
naming ``offset`` and ``limit`` (the pinned substrings — the
pre-phase-131 dead-end wording "to locate what you need — grep
searches the whole document" is gone).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import select, text
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.agent import READ_TRUNCATION_NOTICE
from app.rag.retriever import TRUNCATION_MARKER
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from e2e.mock_llm import READ_LINES_LIMIT, READ_LINES_PATTERN
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns
# its port in a combined run — this module app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive
# the wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_READLINES", "8148"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The owner's read cap (TODO.md L7 — the env under which the owner
#: hit the stuck scenario): the app under test boots with it.
READ_CAP = 24000
SOURCE = "linedoc" # the local directory's basename = the source name
BIG_REL = "big.md"
BIG_SP = f"{SOURCE}/{BIG_REL}" # the combined read identity
#: The marker line the flow greps for: the mock's canned pattern
#: (``READ_LINES_PATTERN`` — imported so the mock can never drift
#: from the fixture) + the owner's question wording. The fixture
#: document carries exactly one line containing it — the range
#: read's target; the echo assertion asserts on this very string.
MARKER_LINE_TEXT = f"{READ_LINES_PATTERN} the trap components are documented here"
# The fixture's layout (deterministic — see the module docstring):
# 2 000 numbered lines under 200 headings (10 lines each), the
# marker replacing the 1 500th numbered line.
NUMBERED_LINES = 2000
LINES_PER_BLOCK = 10
MARKER_NUMBERED = 1500
_FILLER_WORDS = [
"quiet", "mirror", "vault", "raid", "pool", "chain", "slot", "cycle",
"guard", "probe", "audit", "manifest", "retention", "rotation",
"label", "window", "image", "snapshot", "archive", "copy", "verify",
"steady", "order", "exact", "clean", "whole", "night", "weekly",
"plan", "track", "mark", "note", "file", "page", "entry", "index",
"column", "row", "cell",
]
def _big_doc() -> str:
"""The ~99 KB fixture document (deterministic — module docstring).
The H1, then 200 ``##`` headings (one per 10 numbered lines), each
followed by its 10 numbered lines (``Line <i>: …``) — the marker
line REPLACES the ``MARKER_NUMBERED``-th numbered line. The line
numbers are exact: the marker's true line is computed from the
returned string (``splitlines().index(…) + 1``), never a literal.
"""
lines: list[str] = ["# Lined document", ""]
numbered = 0
wi = 0
for block in range(1, NUMBERED_LINES // LINES_PER_BLOCK + 1):
lines.append(f"## Lines of the big lined read {block:04d}")
for _ in range(LINES_PER_BLOCK):
numbered += 1
if numbered == MARKER_NUMBERED:
lines.append(MARKER_LINE_TEXT)
else:
words = _FILLER_WORDS
lines.append(
f"Line {numbered}: "
+ " ".join(words[wi % len(words) : wi % len(words) + 6])
)
wi += 6
return "\n".join(lines)
BIG_DOC = _big_doc()
BIG_DOC_TOTAL = len(BIG_DOC)
#: The marker's true (1-based) line — computed from the very string
#: written to the fixture (the determinism pin: the assertions use
#: this, never a literal).
MARKER_LINE_NO = BIG_DOC.splitlines().index(MARKER_LINE_TEXT) + 1
# The suite's length contracts (the task's pinned-count assumptions —
# the assertions use ``len(BIG_DOC)`` of the very string written to
# the fixture, and ``synced_kb`` pins the stored content
# byte-identical to it): the document is OVER the owner's cap (the
# plain read truncates), the marker is FAR past the cap (the first
# cap chars hold NO marker — the stuck premise), the marker is unique
# (the grep has exactly one match line), and the range slice is UNDER
# the cap (the range read does not truncate — no ``tool_result``
# frame on the range turn).
assert BIG_DOC_TOTAL > READ_CAP, BIG_DOC_TOTAL
assert MARKER_LINE_TEXT not in BIG_DOC[:READ_CAP], (
f"the first {READ_CAP} chars must hold no marker"
)
assert BIG_DOC.count(MARKER_LINE_TEXT) == 1
_slice = "\n".join(
BIG_DOC.splitlines()[MARKER_LINE_NO - 1 : MARKER_LINE_NO - 1 + READ_LINES_LIMIT]
)
assert len(_slice) < READ_CAP, len(_slice)
# The REWRITTEN notice for this fixture's counts (the app.rag.agent
# constant). The bubble renders the raw LLM-side text through
# renderMarkdown (escape-first, markdown.js) — the notice's inline-
# code backticks (`` `offset` `` / `` `limit` ``) become <code>
# elements, so the DOM text_content() carries the notice with the
# backticks stripped (the test_read_truncation_cap convention).
NOTICE = READ_TRUNCATION_NOTICE.format(shown=READ_CAP, total=BIG_DOC_TOTAL)
NOTICE_RENDERED = NOTICE.replace("`", "")
# The pinned substrings (task 04): the notice names BOTH range
# arguments — the workflow teaching.
assert "offset" in NOTICE and "limit" in NOTICE
# The pre-phase-131 dead-end wording (must be GONE from the
# surfaced truncation).
OLD_DEAD_END_1 = "to locate what you need"
OLD_DEAD_END_2 = "grep searches the whole document"
# The scripted turns. Q_RANGE carries the mock's ``READ_LINES_TRIGGER``
# (the range dance); Q_PLAIN carries the mock's existing
# ``READ_CAP_TRIGGER`` (``read the capped document: read
# source/path`` — the plain-read flow's scripted call, the
# test_read_truncation_cap convention) on this suite's large
# document. The shared ``trap components`` words keep both turns
# GROUNDED on the single-document KB (the mock-calibrated gate — see
# the module docstring).
Q_RANGE = f"Read the lines of {BIG_SP} — what are the trap components?"
Q_PLAIN = f"Read the capped document: read {BIG_SP} — the trap components, plain read check"
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def linedoc_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The story's local-directory source: one host temp dir (the app
server runs on the same host, so the path is visible to it)
holding the ONE generated large document. The directory's
basename is the source name (``kind=local``, phase 38)."""
root = tmp_path_factory.mktemp("bor_read_lines") / SOURCE
root.mkdir()
(root / BIG_REL).write_text(BIG_DOC, encoding="utf-8")
assert (root / BIG_REL).read_text(encoding="utf-8") == BIG_DOC
assert not (root / ".git").exists() # the local-kind story: NOT git
return root
@pytest.fixture(scope="module")
def app_server(mock_llm: int, linedoc_dirs: Path) -> Iterator[str]:
"""The real app under test — per-module env (the conftest
pattern, cf. ``test_read_truncation_cap.py``): NO
``BOR_GIT_SOURCES`` (the env fallback is git-only — the source
here is a DB-registered local directory), the mock LLM, the
mock-calibrated gate (threshold 0.30 + lexical floor 0.15 — the
conftest pair; the code-default floor 0.35 would refuse boot
against the 0.30 threshold, phase 112) — and the SUBJECT: the
owner's cap ``BOR_READ_MAX_CHARS=24000`` (TODO.md L7). The
session app is never started in this isolated run, so no port
clash."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern): both scripted turns
# stay GROUNDED (the questions' best-chunk cosine ~0.23 clears
# the 0.15 floor with FTS hits — the marker chunk wins).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
# The read document must clear the citation bar into
# done.sources (phase 119 A1 — the range turn asserts it).
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget (the
# conftest leak-guard pattern).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's settings (the app reads
# it from cwd) — override the source list with an EMPTY value (the
# env var beats the .env file): the registry must hold EXACTLY the
# local directory this suite registers (a leftover env git list
# would pollute the KB the scripted turns run against).
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under
# test.
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
# THE SUBJECT: the owner's cap (TODO.md L7 — the env under which
# the owner hit the stuck scenario; the production default of
# 128 000 stays pinned in tests/unit/test_config.py).
env["BOR_READ_MAX_CHARS"] = str(READ_CAP)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E
suites share one Postgres, so a leftover git_sources row or
document would pollute the retrieval the scripted turns run
against (the single-document KB's gate margins are pinned
against EXACTLY this one document)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit()
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state (the test_ls_tree_drilldown pattern, over
plain httpx — this fixture has no browser page yet)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
time.sleep(0.5)
raise AssertionError(f"sync did not reach a terminal state: {body}")
@pytest.fixture(scope="module")
def synced_kb(app_server: str, linedoc_dirs: Path) -> None:
"""The story's precondition: the one-source KB synced under the
deterministic mock. Registers the temp directory through the
authenticated API (the ``test_local_directory_sources.py``
pattern), runs the REAL in-process sync (``POST /api/sync`` —
walk → chunk → embed → overview → version bump), pins the stored
content byte-identical to the fixture string (the ``chars_total``
every assertion below uses is ``len(BIG_DOC)`` of the very string
written to the directory)."""
_truncate_all()
with httpx.Client(base_url=app_server, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(linedoc_dirs)}
)
assert r.status_code == 201, r.text
r = client.post("/api/sync")
assert r.status_code == 202, r.text
body = _wait_sync_done_http(client)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 1, detail
assert detail["pruned"] == 0, detail
# The import stored the fixture string BYTE-IDENTICALLY — the
# ``chars_total`` every assertion below uses is ``len()`` of the
# very string written to the directory.
with SessionLocal() as db:
stored = db.scalar(
select(Document.content).where(
Document.source == SOURCE, Document.path == BIG_REL
)
)
assert stored == BIG_DOC, "stored content drifted from the fixture"
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
"""Per-test query_log isolation (the KB itself is module-scoped —
the scripted turns never change it, so the registry persists
across the tests of this module)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
# --------------------------------------------------------------------------
# Page helpers (the test_read_truncation_cap / test_ls_tree_drilldown
# house patterns)
# --------------------------------------------------------------------------
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions
#: for the ``tool`` / ``tool_result`` frames, independent of the UI
#: rendering.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_page_hooks(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _frames(page: Page, deadline_s: float = 120.0) -> list[dict]:
"""The SSE frames captured since the last submit (``_submit``
clears the buffer), once the hook's background read settles. The
default deadline is generous: the plain-read turn's echo streams
the whole 24 000-char truncated result (the mock's 12-char /
0.02 s pacing ≈ 40 s)."""
deadline = time.monotonic() + deadline_s
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool"]
def _result_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool_result"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page, timeout_ms: int = 120_000) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (the phase-48 settle wait; the timeout is a parameter —
the plain-read turn's 24 000-char echo streams for ~40 s)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text(
"", timeout=timeout_ms
)
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout_ms)
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout_ms)
def _last_brain(page: Page) -> Locator:
return page.locator(".msg.brain").last
# --------------------------------------------------------------------------
# 1. The range turn: the owner's grep → read(offset/limit) dance on
# the wire, grounded in the large doc's tail
# --------------------------------------------------------------------------
def test_range_read_flow_over_sse(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_RANGE)
_wait_settled(page)
frames = _frames(page)
# The owner's dance on the wire — IN ORDER, asserted on the
# frames' ARGS (not a substring): the grep (the canned pattern),
# then the range read — path = the doc's combined identity,
# offset = the marker's TRUE line (the mock parsed it from the
# grep result's first match line — the mock does the owner's
# exact dance), limit = READ_LINES_LIMIT. The range fields ride
# the SAME frame (the phase-131 wire surface — absent keys on
# every non-range call).
assert _tool_frames(frames) == [
{"type": "tool", "name": "grep", "argument": READ_LINES_PATTERN},
{
"type": "tool",
"name": "read",
"argument": BIG_SP,
"offset": MARKER_LINE_NO,
"limit": READ_LINES_LIMIT,
},
], _tool_frames(frames)
# The range read did NOT truncate (30 lines ≈ 1 300 chars << the
# 24 000 cap) — it streams NO tool_result frame (one frame = one
# noteworthy event, the phase-95 additive contract).
assert _result_frames(frames) == [], _result_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# The read document is the turn's cited source (phase 119 A1 —
# done.sources is the READ docs).
assert any(
s["path"] == BIG_REL and s["source"] == SOURCE for s in done["sources"]
), done["sources"]
# The GROUNDED echo — the range reached the model's context and
# landed in the answer: ``Found it at line <N>`` (N == the
# marker's true line) + the marker text itself (the answer is
# grounded in the large doc's TAIL — far past the 24 000-char
# cap a plain read could reach)…
text = _last_brain(page).locator(".bubble").text_content() or ""
assert f"Found it at line {MARKER_LINE_NO}." in text, text
assert MARKER_LINE_TEXT in text, text
# …and NO truncation-notice dead-end (the pre-phase stuck shape —
# "the rest is NOT shown, use grep" — is not the answer: the
# range read landed).
assert TRUNCATION_MARKER not in text, text
assert "TRUNCATED —" not in text, text
# --------------------------------------------------------------------------
# 2. The plain-read turn: the truncation still fires at the owner's
# cap, and the surfaced notice teaches the workflow (the dead-end
# is gone)
# --------------------------------------------------------------------------
def test_plain_read_truncation_notice_teaches_the_workflow(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(150_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_PLAIN)
_wait_settled(page)
frames = _frames(page)
# The plain read executed — the tool frame carries NO range keys
# (a plain read: the phase-131 additive fields are ABSENT, never
# null — the pre-phase frame shape, byte-identical).
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": BIG_SP}
], _tool_frames(frames)
# EXACTLY ONE tool_result frame — the pinned counts (the
# owner's cap: showing the first READ_CAP of the fixture's true
# length).
assert _result_frames(frames) == [
{
"type": "tool_result",
"name": "read",
"argument": BIG_SP,
"truncated": True,
"chars_shown": READ_CAP,
"chars_total": BIG_DOC_TOTAL,
}
], _result_frames(frames)
# Frame order: the marker lands AFTER the call is shown and
# BEFORE the answer's first delta (the phase-37/48 tool-line
# timing is untouched — the beat between the two is the
# truncation).
i_tool = next(i for i, f in enumerate(frames) if f.get("type") == "tool")
i_result = next(
i for i, f in enumerate(frames) if f.get("type") == "tool_result"
)
i_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta")
assert i_tool < i_result < i_delta, (i_tool, i_result, i_delta)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# The surfaced truncation — the mock's echo of the read result
# (the LLM-side surface the notice rides on — the house
# scripted-turn lens): the REWRITTEN notice landed in the model's
# context and in the rendered answer, naming BOTH range
# arguments (the workflow teaching: grep reports line numbers,
# then read with `offset` + `limit`)…
text = _last_brain(page).locator(".bubble").text_content() or ""
assert TRUNCATION_MARKER in text, text
assert NOTICE_RENDERED in text, text
# …and the pre-phase-131 dead-end wording is GONE.
assert OLD_DEAD_END_1 not in text, text
assert OLD_DEAD_END_2 not in text, text
+8 -2
View File
@@ -222,6 +222,12 @@ assert SHORT_DOC.count(SHORT_SENTINEL) == 1
# app.rag.agent constant, formatted for this fixture's counts).
MARKER = f" (truncated — showing {READ_CAP} of {CAP_DOC_TOTAL} chars)"
NOTICE = READ_TRUNCATION_NOTICE.format(shown=READ_CAP, total=CAP_DOC_TOTAL)
# The bubble renders the raw LLM-side text through renderMarkdown
# (escape-first, markdown.js) — the phase-131 notice's inline-code
# backticks (`` `offset` `` / `` `limit` ``) become <code> elements, so
# the DOM text_content() carries the notice with the backticks
# stripped.
NOTICE_RENDERED = NOTICE.replace("`", "")
# The scripted turns (the mock's ``READ_CAP_TRIGGER`` questions — each
# carries its own tool call after the colon; the turn-specific flavor
@@ -679,7 +685,7 @@ def test_truncated_read_frame_order_live_marker_and_llm_notice(
bubble = _last_brain(page).locator(".bubble")
text = bubble.text_content() or ""
assert TRUNCATION_MARKER in text, text
assert NOTICE in text, text
assert NOTICE_RENDERED in text, text
# The first cap chars reached the model…
assert HEAD_SENTINEL in text, text
# …and the past-the-cap tail did NOT (the cut is real, not cosmetic).
@@ -761,7 +767,7 @@ def test_truncated_read_save_and_shared_fidelity(
# told about (same stored text, same renderer).
anon_text = anon.locator(".msg.brain .bubble").first.text_content() or ""
assert TRUNCATION_MARKER in anon_text, anon_text
assert NOTICE in anon_text, anon_text
assert NOTICE_RENDERED in anon_text, anon_text
assert HEAD_SENTINEL in anon_text, anon_text
assert TAIL_SENTINEL not in anon_text, anon_text
anon_ctx.close()
+4 -2
View File
@@ -197,8 +197,10 @@ def test_agent_tools_offers_the_harness_aligned_surface() -> None:
assert set(ls["properties"]) == {"path"}
read = by_name["read"]["function"]["parameters"]
assert read["type"] == "object"
assert read["required"] == ["path"]
assert set(read["properties"]) == {"path"}
assert read["required"] == ["path"] # offset/limit optional (131)
assert set(read["properties"]) == {"path", "offset", "limit"}
assert read["properties"]["offset"]["type"] == "integer"
assert read["properties"]["limit"]["type"] == "integer"
grep = by_name["grep"]["function"]["parameters"]
assert grep["type"] == "object"
assert grep["required"] == ["pattern"]
@@ -0,0 +1,495 @@
"""Integration: phase 131 (task 03) — the owner's handbook repro.
The exact incident from ``TODO.md`` L7: the agent was asked "What are
the components of the find traps spell?" over the real D&D player's
handbook (``test_sources/players_handbook.pdf`` — the untracked fixture
from ``pdf_tests.tar``), it ``grep``'d the text and FOUND it "including
line number", and then dead-ended: "However, read takes a path, not a
line number." This suite proves the fixed loop closes the dance on the
REAL document: the handbook is indexed through the REAL importer
(:func:`app.rag.importer.import_sources` — the deterministic in-process
:class:`~tests.fakes.FakeEmbedder` stands in for the embed/summary
calls, so the ~1200-chunk index build stays fast and offline), and a
scripted fake LLM (the ``tests/unit/test_agent.py`` round pattern)
drives the turn with the owner's ``BOR_READ_MAX_CHARS=24000`` in force:
* the **grep-first** repro — round 1 a ``grep`` (``pattern="find
traps"``, lowercase — grep is case-insensitive), round 2 a ``read``
with ``offset`` = the line the REAL grep result reported (parsed,
never hardcoded) + ``limit`` = 40, round 3 the answer: the read
result's content, verbatim (the phase-94 "the read document reached
the context" echo convention — the loop's reply IS the slice).
Exactly 3 rounds, ``tool_calls == 2`` (the owner's stuck loop made 2
and dead-ended — the fixed loop closes in 3).
* the **plain-read-first** variant — round 1 a plain ``read`` (the
handbook truncates at 24000 with the REWRITTEN phase-131 notice,
which names ``offset``/``limit`` — the dead-end is gone), round 2
the grep, round 3 the range read of the ALREADY-TRUNCATED document
(the phase-131 dedupe matrix's truncated-then-range row, end-to-end
on the real document — it PROCEEDS; the ``REREAD_TRUNCATED``
teaching is never needed because the model went straight to the
range), round 4 the echo answer.
The expected find-traps text is DERIVED from the fixture at test time
(extract the PDF's text with the phase-126
:func:`app.rag.pdf.extract_pdf_text`, grep it the same way the model
does — :func:`app.rag.agent.grep_document`'s case-insensitive fixed
substring — and take the passage's first 60 chars): no hardcoded
handbook wording, so the test stays true if the fixture is
re-extracted.
House KB pattern (``test_agent_tools.py`` / ``test_importer_e2e.py``):
the ``documents`` table is truncated around each test, so the whole-KB
``grep`` sees EXACTLY the handbook — the grep's line numbers are then
the extracted text's line numbers, one-to-one. The untracked fixture
is only ever COPIED into a temp source dir, never mutated.
Requires: podman compose up -d db
"""
from __future__ import annotations
import asyncio
import re
import shutil
from collections.abc import AsyncIterator, Iterator
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import pytest
from sqlalchemy import delete, select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import SessionLocal
from app.models import Document, GitSource
from app.rag import agent
from app.rag.agent import (
AGENT_TOOLS,
READ_TRUNCATION_NOTICE,
REREAD_TRUNCATED,
AgentHolder,
run_agent,
)
from app.rag.importer import import_sources
from app.rag.llm import (
LLMClient,
RetryPiece,
StreamPiece,
ToolCallPiece,
ToolResultPiece,
)
from app.rag.pdf import extract_pdf_text
from app.rag.retriever import TRUNCATION_MARKER
from tests.fakes import FakeEmbedder
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
#: The repo root (this file lives in ``tests/integration/``).
REPO_ROOT = Path(__file__).resolve().parents[2]
#: The owner's real fixture (untracked — unpacked from
#: ``test_sources/pdf_tests.tar``): the D&D player's handbook.
HANDBOOK = REPO_ROOT / "test_sources" / "players_handbook.pdf"
#: The imported identity: the source dir is named ``test_sources``
#: (the importer's ``source = root.name``) and the PDF sits directly
#: inside it.
SOURCE = "test_sources"
PDF_NAME = "players_handbook.pdf"
IDENTITY = f"{SOURCE}/{PDF_NAME}"
#: The owner's exact question (``TODO.md`` L7) — the turn's user
#: message; the scripted loop answers it through the tools.
QUESTION = "What are the components of the find traps spell?"
#: The owner's env: ``BOR_READ_MAX_CHARS=24000`` (the handbook is far
#: longer, so a plain read of it truncates).
READ_CAP = 24_000
#: The owner's pattern — lowercase (grep is case-insensitive).
PATTERN = "find traps"
#: The scripted range read's line count (both variants).
RANGE_LIMIT = 40
pytestmark = pytest.mark.skipif(
not HANDBOOK.is_file(),
reason=(
"test_sources/players_handbook.pdf is missing — unpack "
"test_sources/pdf_tests.tar (the untracked owner fixture)"
),
)
def _settings() -> Settings:
"""The turn's settings: the owner's read cap in force and the code
default import extensions plus ``pdf`` (the owner's
``BOR_IMPORT_EXTENSIONS`` names it — the handbook is an indexed
source). ``_env_file=None`` keeps an operator's local ``.env`` out
of the pins."""
defaults = Settings.model_fields["import_extensions"].default
kwargs: dict[str, Any] = {
"_env_file": None,
"import_extensions": f"{defaults},pdf",
"read_max_chars": READ_CAP,
}
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
def _expected_passage() -> str:
"""The first 60 chars of the handbook's find-traps passage, DERIVED
from the fixture at test time (the task's no-hardcoded-wording
rule): the PDF's extracted text (the same text the importer stored)
is grepped the same way the model does —
:func:`app.rag.agent.grep_document`, the tool's case-insensitive
fixed-substring semantics — and the passage is the
:data:`RANGE_LIMIT` lines from the FIRST grep match (the exact
window the scripted loop reads). If the fixture is re-extracted the
line numbers and wording are re-derived, so the assertion stays
true."""
content = extract_pdf_text(HANDBOOK)
first_line, _ = agent.grep_document(content, PATTERN)[0]
lines = content.splitlines()
return "\n".join(lines[first_line - 1 : first_line - 1 + RANGE_LIMIT])[:60]
def _first_grep_line(messages: list[dict[str, Any]], identity: str) -> int:
"""The first line number the loop's grep reported for *identity* —
parsed from the REAL grep result the loop produced (the task's
no-hardcoded-line rule). The last ``tool`` message carries the
grep result, one ``source/path:line: text`` line per match; the
first match line for the handbook names the line the scripted
range read takes."""
last_tool = next(m for m in reversed(messages) if m.get("role") == "tool")
content = last_tool["content"]
assert isinstance(content, str)
for line in content.splitlines():
if line.startswith(f"{identity}:"):
match = re.match(rf"^{re.escape(identity)}:(\d+):", line)
assert match is not None, f"unparseable grep line: {line!r}"
return int(match.group(1))
raise AssertionError(f"no grep result line for {identity!r}")
class HandbookAgentLLM:
"""The scripted owner-scenario loop (the ``test_agent.py`` round
pattern) — deterministic, and it parses the REAL line number from
the grep result the loop produced (never a hardcoded line):
``plain_first=False`` (the grep-first repro — test 1):
* round 1 → ``grep`` (``pattern="find traps"``);
* round 2 → ``read`` (``path`` = the handbook identity, ``offset``
= the first grep-reported line, ``limit`` = 40);
* round 3 → the answer: the read result's content, verbatim (the
phase-94 echo convention — the loop's reply IS the slice).
``plain_first=True`` (the plain-read-first variant — test 2):
* round 1 → plain ``read`` (``path``);
* round 2 → ``grep`` (``pattern="find traps"``);
* round 3 → ``read`` (``path``, ``offset`` = the first grep-
reported line, ``limit`` = 40) — the dedupe matrix's
truncated-then-range row on the real document;
* round 4 → the answer: the read result's content, verbatim.
Every ``chat_stream`` request's messages and tools are recorded
(``requests``); the scripted :class:`ToolCallPiece` s, in call
order, in ``calls``. The canned answer pieces are fed through the
round's :class:`ScaffoldingFilter` exactly like
``LLMClient.chat_stream`` (the ``test_agent.py`` ScriptedLLM
pattern)."""
def __init__(self, identity: str, *, plain_first: bool) -> None:
self.identity = identity
self.plain_first = plain_first
self.requests: list[
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
] = []
self.calls: list[ToolCallPiece] = []
def _next_call(self, index: int, messages: list[dict[str, Any]]) -> ToolCallPiece | None:
"""The scripted call for request *index* — ``None`` on the
final (answer) round. The range read's ``offset`` is parsed
from the grep result just delivered."""
if self.plain_first:
if index == 0:
return ToolCallPiece(id="call_1", name="read", arguments={"path": self.identity})
if index == 1:
return ToolCallPiece(id="call_2", name="grep", arguments={"pattern": PATTERN})
if index == 2:
return ToolCallPiece(
id="call_3",
name="read",
arguments={
"path": self.identity,
"offset": _first_grep_line(messages, self.identity),
"limit": RANGE_LIMIT,
},
)
else:
if index == 0:
return ToolCallPiece(id="call_1", name="grep", arguments={"pattern": PATTERN})
if index == 1:
return ToolCallPiece(
id="call_2",
name="read",
arguments={
"path": self.identity,
"offset": _first_grep_line(messages, self.identity),
"limit": RANGE_LIMIT,
},
)
return None
async def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
scaffolding: ScaffoldingFilter | None = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
self.requests.append((deepcopy(messages), deepcopy(tools)))
call = self._next_call(len(self.requests) - 1, messages)
if call is not None:
self.calls.append(call)
yield call
return
# The answer round: the last tool result (the range read's
# result) — the loop's reply IS the slice, verbatim.
last_tool = next(m for m in reversed(messages) if m.get("role") == "tool")
content = last_tool["content"]
assert isinstance(content, str)
if scaffolding is None:
yield StreamPiece("content", content)
return
cleaned = scaffolding.feed(content)
if cleaned:
yield StreamPiece("content", cleaned)
tail = scaffolding.flush()
if tail:
yield StreamPiece("content", tail)
async def _consume(
llm: HandbookAgentLLM,
holder: AgentHolder,
settings: Settings,
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
"""Consume one scripted ``run_agent`` turn (the owner's question,
no seed docs). SEC-14-04: a short-lived session per tool call —
the same pattern as production and the phase-70 integration suite."""
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
async for piece in run_agent(
cast("LLMClient", llm),
lambda: SessionLocal(),
system_prompt="SYSTEM_PROMPT",
user_message=QUESTION,
seed_docs=[],
settings=settings,
holder=holder,
):
out.append(piece)
return out
def _answer(pieces: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]) -> str:
"""The turn's final answer text (the content pieces)."""
return "".join(
piece.text for piece in pieces if isinstance(piece, StreamPiece) and piece.kind == "content"
)
@pytest.fixture()
def handbook_kb(db: Session, tmp_path: Path) -> Iterator[Document]:
"""The real handbook, indexed once per test through the REAL
importer: a temp source dir containing ``test_sources/
players_handbook.pdf`` (a COPY — the untracked fixture is never
mutated), registered as a local source, imported with the
deterministic in-process :class:`~tests.fakes.FakeEmbedder` (the
~1200-chunk embed is instant — the whole test stays well under the
task's ~60 s bar). Yields the indexed
:class:`~app.models.Document` row.
The house KB pattern: ``chunks``/``documents``/``folder_summaries``
are truncated around the test, so the whole-KB ``grep`` sees
EXACTLY the handbook (its line numbers are the extracted text's
line numbers, one-to-one)."""
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.commit()
root = tmp_path / SOURCE
root.mkdir()
shutil.copy2(HANDBOOK, root / PDF_NAME)
row = GitSource(url=str(root), kind="local", path=str(root))
db.add(row)
db.commit()
fake = FakeEmbedder(dim=768)
fake.settings = _settings() # the owner's cap + the pdf token
asyncio.run(import_sources([root], fake, session=db))
doc = db.scalar(
select(Document).where(Document.source == SOURCE, Document.path == PDF_NAME)
)
assert doc is not None, "the handbook did not import"
assert len(doc.content) > READ_CAP, "the handbook must outgrow the owner's read cap"
yield doc
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.execute(delete(GitSource).where(GitSource.id == row.id))
db.commit()
db.expire_all()
def test_grep_then_range_read_answers_the_find_traps_question(
handbook_kb: Document,
) -> None:
"""The owner's stuck loop, fixed — grep → range read → answer:
exactly 3 rounds with ``tool_calls == 2`` (the owner's stuck loop
made 2 and dead-ended), the read call carries the grep-reported
``offset`` + ``limit`` (parsed, not literal), and the final answer
— the read result, verbatim — contains the handbook's REAL
find-traps text (derived from the fixture at test time)."""
doc = handbook_kb
expected = _expected_passage()
llm = HandbookAgentLLM(IDENTITY, plain_first=False)
holder = AgentHolder()
pieces = asyncio.run(_consume(llm, holder, _settings()))
# Exactly 3 rounds: grep → range read → answer (the fixed loop
# closes where the owner's stuck loop dead-ended).
assert len(llm.requests) == 3
assert [call.name for call in llm.calls] == ["grep", "read"]
# The tools stay offered on every request (phase 45 — no budgets).
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS
# Round 1: the lowercase pattern (grep is case-insensitive).
assert llm.calls[0].arguments == {"pattern": PATTERN}
# The grep result the loop produced: the handbook's REAL match
# lines (``source/path:line: text``), the first naming the line
# the range read must take.
grep_result = llm.requests[1][0][-1]
assert grep_result["role"] == "tool"
assert isinstance(grep_result["content"], str)
assert grep_result["content"].startswith(f"{IDENTITY}:")
grep_line = _first_grep_line(llm.requests[1][0], IDENTITY)
# Round 2: the read call carried offset/limit — offset is the
# grep-reported line (parsed from the real grep result, never a
# literal), limit 40.
read_call = llm.calls[1]
assert read_call.arguments == {
"path": IDENTITY,
"offset": grep_line,
"limit": RANGE_LIMIT,
}
# The range read's result: the byte-identical header lines, the
# true extent on the third line, the slice below.
read_result = llm.requests[2][0][-1]
assert read_result["role"] == "tool"
assert isinstance(read_result["content"], str)
total_lines = len(doc.content.splitlines())
assert read_result["content"].startswith(f"Document {IDENTITY}:\n")
assert (
f"lines: {grep_line}-{grep_line + RANGE_LIMIT - 1} of {total_lines}\n"
in read_result["content"]
)
# Round 3: the answer IS the read result (the phase-94 echo
# convention) and it carries the REAL find-traps text.
answer = _answer(pieces)
assert answer == read_result["content"]
assert expected in answer
# Executed-call bookkeeping: both calls counted; the doc read into
# the context exactly once.
assert holder.tool_calls == 2
assert [d.id for d in holder.read_docs] == [doc.id]
assert holder.read_truncations == [] # the range slice fits the cap
def test_plain_read_truncates_then_range_read_answers_the_find_traps_question(
handbook_kb: Document,
) -> None:
"""The plain-read-first variant on the REAL document: round 1's
plain read truncates at the owner's 24000 cap with the REWRITTEN
phase-131 notice (it names ``offset``/``limit`` — the dead-end is
gone) and surfaces a truncation signal; round 2 greps; round 3's
range read of the ALREADY-TRUNCATED document PROCEEDS (the dedupe
matrix's truncated-then-range row, end-to-end — the
``REREAD_TRUNCATED`` teaching is never needed because the model
went straight to the range); round 4 answers with the slice."""
doc = handbook_kb
expected = _expected_passage()
llm = HandbookAgentLLM(IDENTITY, plain_first=True)
holder = AgentHolder()
pieces = asyncio.run(_consume(llm, holder, _settings()))
# Four rounds: plain read → grep → range read → answer.
assert len(llm.requests) == 4
assert [call.name for call in llm.calls] == ["read", "grep", "read"]
# Round 1: the PLAIN read (no range arguments at all).
assert llm.calls[0].arguments == {"path": IDENTITY}
# Round 1's result: cut at the owner's cap, the shared marker +
# the REWRITTEN notice (``{total}`` = the handbook's true length,
# ``{shown}`` = the cap) — the notice names the offset/limit
# workflow the loop then follows.
plain_result = llm.requests[1][0][-1]
assert plain_result["role"] == "tool"
assert isinstance(plain_result["content"], str)
notice = READ_TRUNCATION_NOTICE.format(shown=READ_CAP, total=len(doc.content))
assert plain_result["content"].startswith(f"Document {IDENTITY}:\n")
assert plain_result["content"].endswith(f"\n{TRUNCATION_MARKER}\n{notice}")
# The truncation surfaced to the API layer as a ToolResultPiece
# (the SSE ``tool_result`` frame's wire value).
truncations = [p for p in pieces if isinstance(p, ToolResultPiece)]
assert [(p.argument, p.chars_shown, p.chars_total) for p in truncations] == [
(IDENTITY, READ_CAP, len(doc.content))
]
# Round 3: the range read of the truncated doc — offset is the
# grep-reported line (parsed), limit 40 — and the dedupe matrix
# lets it PROCEED (no ``REREAD_TRUNCATED`` teaching anywhere in
# the loop's tool results).
assert llm.calls[1].arguments == {"pattern": PATTERN}
grep_line = _first_grep_line(llm.requests[2][0], IDENTITY)
read_call = llm.calls[2]
assert read_call.arguments == {
"path": IDENTITY,
"offset": grep_line,
"limit": RANGE_LIMIT,
}
reread = REREAD_TRUNCATED.format(shown=READ_CAP, source=SOURCE, path=PDF_NAME)
tool_results = [
m["content"]
for messages, _tools in llm.requests
for m in messages
if m.get("role") == "tool" and isinstance(m["content"], str)
]
assert reread not in tool_results
# The range read's result: the true extent on the third line.
read_result = llm.requests[3][0][-1]
assert read_result["role"] == "tool"
assert isinstance(read_result["content"], str)
total_lines = len(doc.content.splitlines())
assert read_result["content"].startswith(f"Document {IDENTITY}:\n")
assert (
f"lines: {grep_line}-{grep_line + RANGE_LIMIT - 1} of {total_lines}\n"
in read_result["content"]
)
# Round 4: the answer IS the range read's result and it carries
# the REAL find-traps text.
answer = _answer(pieces)
assert answer == read_result["content"]
assert expected in answer
# All three calls executed (the truncated plain read, the grep,
# the range read); the doc is committed to the context exactly
# once; the holder's truncation record is the plain read's tuple.
assert holder.tool_calls == 3
assert [d.id for d in holder.read_docs] == [doc.id]
assert holder.read_truncations == [(IDENTITY, READ_CAP, len(doc.content))]
+755 -28
View File
@@ -17,7 +17,18 @@ unknown-source refusal that counts nothing, the NOT-A-FOLDER teaching
with the parent's subfolders, the 50-file cap + grep-pointer note),
``read`` on the canonical combined form (split at the
FIRST slash, full content, the bare-source-name refusal, the
already-in-context dedupe, missing-args refusals), the phase-68 ``grep``
already-in-context dedupe, missing-args refusals), the phase-131
(task 02) range-read execution (the optional ``offset``/``limit``:
:func:`agent._read_range_args` validation — whole ints ≥ 1, bools
excluded — with the pinned :data:`agent.BAD_RANGE_ARGS` echo, the
six-row dedupe matrix (a plain re-read of a TRUNCATED doc gets the
pinned :data:`agent.REREAD_TRUNCATED` teaching; the truncated-then-
range row PROCEEDS — the stuck-scenario fix —; every other re-read of
a fully-read doc stays ``ALREADY_IN_CONTEXT``),
:data:`agent.OFFSET_PAST_END` (the true line count named), the slice
math + the ``lines: a-b of N`` header shape (plain reads byte-
identical to pre-phase-131), the cap on the slice with the slice-
length tuple ``chars_total``), the phase-68 ``grep``
contract under its new name (the locked A5 pins: fixed substring,
case-insensitive, 20-cap in catalog order, 200-char lines, locator-only
— ``read_docs`` untouched, no-match lines counted), the round cap
@@ -227,9 +238,12 @@ def test_agent_tools_names_and_parameters() -> None:
# the prompt yet); read adds one of them (or any other document)
# to the context; only an already-read document is refused. The
# combined-identity handoff to the `ls` output / the <documents>
# blocks stays; the truncation-notice paragraph (phase 95) and
# the one-call-at-a-time discipline clause (task 05) survive the
# rewrite byte-identical.
# blocks stays; the one-call-at-a-time discipline clause (task
# 05) survives byte-identical. Phase 131 (task 01): the old
# truncation dead-end paragraph ("Follow it and use `grep`
# (pattern) to locate the rest") is replaced by the working
# workflow — grep reports line numbers, then read the part with
# `offset` (1-based first line) + `limit` (number of lines).
assert read["description"] == (
"The <documents> section shows the SUMMARIES of the "
"top-ranked documents — their full texts are NOT in "
@@ -240,17 +254,20 @@ def test_agent_tools_names_and_parameters() -> None:
"document you have already read — its full text is "
"already in your prompt. Very large documents are "
"truncated: you receive the first part plus a TRUNCATED "
"notice naming how many more characters exist — the "
"notice is authoritative, the document did NOT end "
"where it stopped. Follow it and use `grep` (pattern) "
"to locate the rest — it searches the whole document. "
"notice naming how many more characters exist. To read "
"the rest: use `grep` (pattern) to find where your text "
"is — it reports line numbers — then call `read` again "
"on the same document with `offset` (1-based first line) "
"and `limit` (number of lines) to read exactly that "
"part. Do not re-read a document you have already read "
"in full — its full text is already in your prompt. "
"Call one tool at a time — wait for this result before "
"your next call."
)
# The byte-preserved contracts inside the rewrite, pinned as
# substrings: the combined source/path identity, the
# truncation-notice paragraph, the one-call-at-a-time
# discipline sentence.
# phase-131 grep → read(offset, limit) workflow paragraph, the
# one-call-at-a-time discipline sentence.
assert (
"by its combined `source/path` string, exactly as shown in "
"the `ls` output or the <documents> blocks"
@@ -258,10 +275,11 @@ def test_agent_tools_names_and_parameters() -> None:
assert (
"Very large documents are truncated: you receive the first "
"part plus a TRUNCATED notice naming how many more "
"characters exist — the notice is authoritative, the "
"document did NOT end where it stopped. Follow it and use "
"`grep` (pattern) to locate the rest — it searches the "
"whole document."
"characters exist. To read the rest: use `grep` (pattern) "
"to find where your text is — it reports line numbers — "
"then call `read` again on the same document with "
"`offset` (1-based first line) and `limit` (number of "
"lines) to read exactly that part."
) in read["description"]
assert (
"Call one tool at a time — wait for this result before "
@@ -269,9 +287,30 @@ def test_agent_tools_names_and_parameters() -> None:
) in read["description"]
read_params = read["parameters"]
assert read_params["type"] == "object"
# Phase 131 (task 01, LOCKED A2): offset/limit are OPTIONAL —
# ``required`` stays ``["path"]``.
assert read_params["required"] == ["path"]
assert set(read_params["properties"]) == {"path"}
assert set(read_params["properties"]) == {"path", "offset", "limit"}
assert read_params["properties"]["path"]["type"] == "string"
# The line-range parameters (phase 131, task 01, LOCKED A2):
# BOTH integer, 1-based (``offset`` = first line, ``limit`` =
# line count) — the pi-harness ``read`` shape the model was
# trained on. The descriptions carry the grep → read(offset,
# limit) teaching (pinned copy).
assert read_params["properties"]["offset"]["type"] == "integer"
assert read_params["properties"]["limit"]["type"] == "integer"
assert read_params["properties"]["offset"]["description"] == (
"The first line to read, 1-based. Omit it to read from the "
"beginning of the document. Use it to read a specific part "
"of a large document — grep reports the line numbers of "
"what you found."
)
assert read_params["properties"]["limit"]["description"] == (
"The maximum number of lines to read, starting at offset. "
"Omit it to read as many lines as fit under the character "
"cap. Keep it small — you are reading a part, not the "
"document."
)
# The combined source/path string is the canonical document identity
# (phase 70) — the description pins it with a worked example; the
# bare-path contract is stated up front (phase 72, task 02). Phase
@@ -303,13 +342,14 @@ def test_agent_tools_names_and_parameters() -> None:
"Search the indexed documents for an exact string "
"(case-insensitive) and return up to 20 matching lines "
"as `source/path:line: text` — a locator, not a "
"context-adder: read the winner with `read`. The "
"pattern is a plain substring, NEVER a regex — if a "
"pattern with regex syntax (like '.*' or '\\.') comes "
"back with no matches, retry with the plain text you "
"expect to see. For a normal search pass ONLY `pattern` "
"— it searches every document and that is how you "
"search the knowledge base; never pass a source name "
"context-adder: read the winner with `read` — for a "
"large document, use the reported line number as "
"`offset`. The pattern is a plain substring, NEVER a "
"regex — if a pattern with regex syntax (like '.*' or "
"'\\.') comes back with no matches, retry with the plain "
"text you expect to see. For a normal search pass ONLY "
"`pattern` — it searches every document and that is how "
"you search the knowledge base; never pass a source name "
"as `path` (a source name is not a document). Call one "
"tool at a time — wait for this result before your "
"next call."
@@ -1956,16 +1996,27 @@ def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
def test_read_truncation_notice_is_pinned() -> None:
"""Phase 95: the notice copy is pinned — it names the true length
(``{total}``), the cap kept (``{shown}``), states the rest is NOT
shown (the document did not end where it stopped), and points at
``grep`` (which searches the whole document)."""
"""Phase 95 (REWRITTEN phase 131, task 01): the notice copy is
pinned — it names the true length (``{total}``), the cap kept
(``{shown}``), states the rest is NOT shown (the document did
not end where it stopped), and teaches the working workflow —
the old dead-end is gone: ``grep`` reports line numbers, then
read that part with ``offset`` / ``limit`` (BOTH parameters
named in the notice itself)."""
assert READ_TRUNCATION_NOTICE.format(shown=100, total=250) == (
"TRUNCATED — this document is 250 characters; only the first "
"100 are in your context. The rest is NOT shown. Use grep "
"(pattern) to locate what you need — grep searches the whole "
"document."
"(pattern) to find what you need — it reports line numbers — "
"then read that part with `offset` (1-based first line) and "
"`limit` (number of lines)."
)
# Both line-range parameters are named in the notice — the model
# stuck at the truncation sees the exact follow-up call.
assert "`offset`" in READ_TRUNCATION_NOTICE
assert "`limit`" in READ_TRUNCATION_NOTICE
# The old dead-end sentence (no way to act on the line number)
# is gone.
assert "to locate what you need" not in READ_TRUNCATION_NOTICE
def test_run_agent_yields_tool_result_after_tool_frame_before_next_round(
@@ -2081,6 +2132,682 @@ def test_read_truncation_paths_with_suggested_seed(
assert holder2.tool_calls == 0 and holder2.read_docs == []
# ---------- phase 131 (task 02): the range-read execution (offset/limit) ----------
def _lines_doc(source: str, path: str, n_lines: int, prefix: str = "line ") -> Document:
"""A fixture document of EXACTLY *n_lines* lines (``prefix{i}``)."""
content = "\n".join(f"{prefix}{i}" for i in range(1, n_lines + 1))
return _doc(source, path, path, content)
def test_bad_range_args_is_pinned() -> None:
"""Phase 131 (task 02): the range-argument validation refusal is
pinned — the contract (whole line numbers ≥ 1) with the bad values
echoed (``!r`` — an absent value prints as ``None``)."""
assert agent.BAD_RANGE_ARGS.format(offset=2.5, limit=None) == (
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
"(you passed offset=2.5, limit=None)."
)
assert agent.BAD_RANGE_ARGS.format(offset=None, limit="10") == (
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
"(you passed offset=None, limit='10')."
)
assert agent.BAD_RANGE_ARGS.format(offset=True, limit=None) == (
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
"(you passed offset=True, limit=None)."
)
def test_reread_truncated_is_pinned() -> None:
"""Phase 131 (task 02): the plain re-read teaching for a TRUNCATED
document is pinned — the shown count, the resolved identity, and
the working workflow (grep reports line numbers → read that part
with ``offset`` + ``limit``)."""
assert agent.REREAD_TRUNCATED.format(shown=100, source="S", path="big.md") == (
"You already have the first 100 characters of S/big.md 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) and `limit` (lines)."
)
def test_offset_past_end_is_pinned() -> None:
"""Phase 131 (task 02): the offset-past-end teaching is pinned —
the RESOLVED identity, the true line count, the grep pointer."""
assert agent.OFFSET_PAST_END.format(
source="S", path="a.md", total_lines=10, offset=11
) == (
"S/a.md is 10 lines long — `offset` 11 is past the end. Use a "
"smaller offset (grep reports the exact line numbers)."
)
def test_read_range_args_plain_and_valid() -> None:
"""Phase 131 (task 02): the pure validator — both absent is a plain
read; valid ints pass through (either one-sided is ok: ``offset``
without ``limit`` reads to the end, ``limit`` without ``offset``
counts from the start — the caller normalizes)."""
assert agent._read_range_args(None, None) == (True, "", None, None)
assert agent._read_range_args(1, None) == (True, "", 1, None)
assert agent._read_range_args(None, 7) == (True, "", None, 7)
assert agent._read_range_args(3, 42) == (True, "", 3, 42)
@pytest.mark.parametrize(
("offset", "limit"),
[
(2.5, None), # a float
("3", None), # a string
(True, None), # a bool (an int subclass — the house guard excludes it)
(0, None), # zero
(-1, None), # a negative
(None, 2.5), # a float limit
(None, "10"), # a string limit
(None, False), # a bool limit
(None, 0), # a zero limit
(None, -3), # a negative limit
],
)
def test_read_range_args_bad_values_are_refused(
offset: Any, limit: Any
) -> None:
"""Phase 131 (task 02): a PRESENT bad value (not a whole int ≥ 1 —
the house ``isinstance(x, int) and not isinstance(x, bool)`` guard,
or ``< 1``) refuses with the values echoed; the range comes back
unusable (``None``/``None``)."""
ok, line, got_offset, got_limit = agent._read_range_args(offset, limit)
assert not ok
assert got_offset is None and got_limit is None
assert line == agent.BAD_RANGE_ARGS.format(offset=offset, limit=limit)
def test_bad_range_args_refused_before_any_lookup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): the validation refusal fires in the flow —
BEFORE the dedupe and the DB resolve (a float ``offset`` with an
unknown path still gets the range refusal, not the no-document
line): counts in nothing, consumes a round, the tools stay
offered."""
def _boom(*_a: Any, **_k: Any) -> None:
raise AssertionError("no DB lookup for a bad range argument")
monkeypatch.setattr(agent, "find_document", _boom)
monkeypatch.setattr(agent, "all_documents", _boom)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/ghost.md", "offset": 2.5}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"] == agent.BAD_RANGE_ARGS.format(
offset=2.5, limit=None
)
assert llm.requests[1][1] == AGENT_TOOLS
def test_bad_range_args_beats_the_dedupe(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): a bad range argument on a document ALREADY
IN CONTEXT is the range refusal, not ``ALREADY_IN_CONTEXT`` — the
validation precedes the dedupe (a refusal either way: no count,
no re-append)."""
doc = _doc("S", "a.md", "A", "A-CONTENT")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/a.md"})],
[
ToolCallPiece(
id="c2", name="read", arguments={"path": "S/a.md", "offset": 0}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [doc] and holder.tool_calls == 1
assert llm.requests[2][0][5]["content"] == agent.BAD_RANGE_ARGS.format(
offset=0, limit=None
)
def test_offset_past_end_refused_naming_line_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): ``offset`` = total+1 on a 10-line doc →
the pinned teaching (the true line count named, the grep pointer)
— a refusal: nothing counted, the doc NOT added to the context (a
corrected retry still proceeds)."""
doc = _lines_doc("S", "ten.md", 10)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/ten.md", "offset": 11}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format(
source="S", path="ten.md", total_lines=10, offset=11
)
def test_offset_past_end_on_one_line_doc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): a 1-line doc — ``offset=2`` is past the
end (the true count is 1); ``offset=1`` with an overshooting
``limit`` clamps to the single line."""
doc = _doc("S", "one.md", "One", "only")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
# (a) offset past the end of the 1-line doc.
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/one.md", "offset": 2}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format(
source="S", path="one.md", total_lines=1, offset=2
)
assert holder.read_docs == [] and holder.tool_calls == 0
# (b) the same doc read from line 1, limit overshoots — clamped.
holder2 = AgentHolder()
llm2 = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/one.md", "offset": 1, "limit": 5},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm2, holder2, _settings()))
assert llm2.requests[1][0][3]["content"] == (
"Document S/one.md:\ndate: 2024-06-15\nlines: 1-1 of 1\nonly"
)
assert holder2.read_docs == [doc] and holder2.tool_calls == 1
# --- the six-row dedupe matrix (one test per row) ---
def test_matrix_row1_plain_read_unread_doc_reads_full(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 1: plain ``read(path)``, doc NOT in ``read_docs`` →
the full read (truncate at the cap as today — the pre-phase-131
path, no ``lines:`` header line)."""
doc = _doc("S", "m1.md", "M1", "one\ntwo\nthree")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m1.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [doc] and holder.tool_calls == 1
assert llm.requests[1][0][3]["content"] == (
"Document S/m1.md:\ndate: 2024-06-15\none\ntwo\nthree"
)
def test_matrix_row2_plain_read_truncated_doc_gets_reread_teaching(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 2 (the stuck scenario's first hop): a plain re-read
of a document whose first read TRUNCATED → the pinned
``REREAD_TRUNCATED`` teaching (the shown count from the matching
truncation tuple — the cap) — a refusal: no re-emit, no count,
no second DB lookup (the raw-arg fast path fires pre-resolve)."""
cap = 100
doc = _lines_doc("S", "m2.md", 20) # 150 chars > the cap
lookups: list[tuple[str, str]] = []
def _find(db: Any, source: str, path: str) -> Document | None:
lookups.append((source, path))
return doc if (source, path) == ("S", "m2.md") else None
monkeypatch.setattr(agent, "find_document", _find)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m2.md"})],
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/m2.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# Round 1: the truncated plain read (the stuck trigger) — the
# pre-phase-131 truncation bytes, the holder tuple recorded.
assert llm.requests[1][0][3]["content"] == (
"Document S/m2.md:\ndate: 2024-06-15\n"
+ doc.content[:cap]
+ "\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
)
assert holder.read_truncations == [("S/m2.md", cap, len(doc.content))]
# Round 2: the plain re-read → the teaching (the shown count = the
# cap from the matching tuple).
assert llm.requests[2][0][5]["content"] == agent.REREAD_TRUNCATED.format(
shown=cap, source="S", path="m2.md"
)
# A refusal: the counters stay at the first read's, no re-append,
# no second lookup.
assert holder.read_docs == [doc]
assert holder.tool_calls == 1
assert lookups == [("S", "m2.md")]
def test_matrix_row3_plain_read_full_doc_stays_already_in_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 3: a plain re-read of a document read IN FULL (never
truncated) → ``ALREADY_IN_CONTEXT`` — byte-identical to
pre-phase-131 (the raw-arg fast path, no DB lookup)."""
doc = _doc("S", "m3.md", "M3", "FULL-TEXT") # fits under any cap
lookups: list[tuple[str, str]] = []
def _find(db: Any, source: str, path: str) -> Document | None:
lookups.append((source, path))
return doc if (source, path) == ("S", "m3.md") else None
monkeypatch.setattr(agent, "find_document", _find)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m3.md"})],
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/m3.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
assert holder.read_docs == [doc] and holder.tool_calls == 1
assert lookups == [("S", "m3.md")] # the re-read deduped pre-lookup
def test_matrix_row4_range_read_truncated_doc_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 4 (the stuck scenario's FIX): after a TRUNCATED plain
read, a range read of the SAME document PROCEEDS — the ``lines:``
header + exactly the named lines. The doc was appended ONCE (the
known set prevents the re-append); the range read counts as a
successful call; the slice fits, so no new truncation tuple."""
cap = 100
doc = _lines_doc("S", "m4.md", 20) # 150 chars > the cap
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m4.md"})],
[
ToolCallPiece(
id="c2",
name="read",
arguments={"path": "S/m4.md", "offset": 5, "limit": 3},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# Round 1: the truncated plain read.
assert llm.requests[1][0][3]["content"].endswith(
"\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
)
# Round 2: the range read PROCEEDED — lines 5-7 of 20, exactly.
assert llm.requests[2][0][5]["content"] == (
"Document S/m4.md:\ndate: 2024-06-15\nlines: 5-7 of 20\n"
"line 5\nline 6\nline 7"
)
assert holder.read_docs == [doc] # appended exactly once
assert holder.tool_calls == 2 # both reads are successful calls
assert holder.read_truncations == [("S/m4.md", cap, len(doc.content))]
def test_matrix_row5_range_read_full_doc_stays_already_in_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 5: a range read of a document read IN FULL (never
truncated) → ``ALREADY_IN_CONTEXT`` — the range is a SUBSET of the
text already in the prompt (the matrix needs the resolved pair, so
the check runs after the resolve)."""
doc = _lines_doc("S", "m5.md", 5)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m5.md"})],
[
ToolCallPiece(
id="c2",
name="read",
arguments={"path": "S/m5.md", "offset": 2, "limit": 2},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
assert holder.read_docs == [doc] and holder.tool_calls == 1
assert holder.read_truncations == []
def test_matrix_row6_range_read_unread_doc_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Matrix row 6: a range read of a NOT-yet-read document PROCEEDS —
the ``lines:`` header + the slice, the doc appended, the call
counted (a first read, plain or range, behaves like a first read)."""
doc = _lines_doc("S", "m6.md", 8)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/m6.md", "offset": 3, "limit": 2},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/m6.md:\ndate: 2024-06-15\nlines: 3-4 of 8\nline 3\nline 4"
)
assert holder.read_docs == [doc] and holder.tool_calls == 1
# --- the slice math + the result shape ---
def test_range_read_offset_one_no_limit_reads_whole_doc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): ``offset=1`` without ``limit`` reads to the
end of the document — the true extent ``1-N of N``, the whole
content (the slice, not the plain-read shape)."""
doc = _doc("S", "r1.md", "R1", "a\nb\nc")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/r1.md", "offset": 1}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/r1.md:\ndate: 2024-06-15\nlines: 1-3 of 3\na\nb\nc"
)
assert holder.read_docs == [doc] and holder.tool_calls == 1
def test_range_read_offset_at_total_reads_last_line(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): ``offset`` = the last line number →
exactly the last line (``N-N of N``)."""
doc = _doc("S", "r2.md", "R2", "a\nb\nc")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/r2.md", "offset": 3, "limit": 5},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/r2.md:\ndate: 2024-06-15\nlines: 3-3 of 3\nc"
)
def test_range_read_limit_overshoot_clamps_to_total(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): an overshooting ``limit`` clamps ``end`` to
the last line (reads to the end, never past it)."""
doc = _doc("S", "r3.md", "R3", "a\nb\nc")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/r3.md", "offset": 2, "limit": 10},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/r3.md:\ndate: 2024-06-15\nlines: 2-3 of 3\nb\nc"
)
def test_range_read_limit_without_offset_counts_from_start(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): ``limit`` WITHOUT ``offset`` is a line count
from the start of the document (offset = 1 — the helper's
documented one-sided semantics)."""
doc = _doc("S", "r4.md", "R4", "a\nb\nc")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/r4.md", "limit": 2}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/r4.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb"
)
def test_range_read_image_doc_carries_marker_after_lines_line(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): a range read of a standalone-IMAGE document
carries the :data:`agent.IMAGE_DOC_MARKER` line exactly where the
plain read puts it — after the header lines (here, after
``lines:``)."""
doc = _doc("S", "img.png", "Img", "IMG-DESC-OF-LINE-1\nline-2-text")
doc.is_image = True
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/img.png", "offset": 2, "limit": 1},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/img.png:\ndate: 2024-06-15\nlines: 2-2 of 2\n"
f"{agent.IMAGE_DOC_MARKER}\n"
"line-2-text"
)
assert holder.read_docs == [doc] and holder.tool_calls == 1
def test_range_read_slice_over_cap_truncates_with_slice_length_total(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): the cap applies to the SLICE — an over-cap
slice is cut at the cap + the shared marker + the pinned notice
(``{total}`` = the SLICE's length), and the holder tuple's
``chars_total`` is the slice's length, NOT the document's (the
loop still yields one ToolResultPiece for it)."""
cap = 20
lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 lines × 33 chars
content = "\n".join(lines)
doc = _doc("S", "cap.md", "Cap", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/cap.md", "offset": 5, "limit": 6},
)
],
[StreamPiece("content", "ans")],
)
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# The slice: lines 5-10 (6 × 33 chars + 5 newlines = 203 > cap).
slice_text = "\n".join(lines[4:10])
assert len(slice_text) == 203
assert len(content) == 339 # the document is much bigger
assert llm.requests[1][0][3]["content"] == (
"Document S/cap.md:\ndate: 2024-06-15\nlines: 5-10 of 10\n"
+ slice_text[:cap]
+ "\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))
)
# The tuple: the argument, the cap kept, the SLICE's length (203,
# not the document's 339).
assert holder.read_truncations == [("S/cap.md", cap, 203)]
assert holder.read_docs == [doc] and holder.tool_calls == 1
# The loop yields the ToolResultPiece with the slice's total.
pieces = [p for p in out if isinstance(p, ToolResultPiece)]
assert len(pieces) == 1
assert pieces[0].argument == "S/cap.md"
assert pieces[0].chars_shown == cap
assert pieces[0].chars_total == 203
def test_range_read_truncated_then_plain_reread_teaching_then_range_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): a TRUNCATED range read (offset=1, no limit
— the whole 339-char doc under a 20-char cap) records the slice
tuple; a plain re-read then gets ``REREAD_TRUNCATED`` with the
shown count from THAT (range) tuple; a subsequent range read still
PROCEEDS (truncated-then-range, regardless of which read
truncated)."""
cap = 20
lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)]
content = "\n".join(lines)
doc = _doc("S", "t.md", "T", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1", name="read", arguments={"path": "S/t.md", "offset": 1}
)
],
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/t.md"})],
[
ToolCallPiece(
id="c3",
name="read",
arguments={"path": "S/t.md", "offset": 5, "limit": 2},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# Round 1: the truncated range read (the whole doc, offset=1) —
# the slice is the whole doc here (339 chars), the tuple's total
# is the slice's length.
assert llm.requests[1][0][3]["content"].startswith(
"Document S/t.md:\ndate: 2024-06-15\nlines: 1-10 of 10\n"
)
assert llm.requests[1][0][3]["content"].endswith(
"\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=339)
)
# Round 2: the plain re-read → the teaching, shown from the (range)
# truncation tuple.
assert llm.requests[2][0][5]["content"] == agent.REREAD_TRUNCATED.format(
shown=cap, source="S", path="t.md"
)
# Round 3: the range read of the truncated doc PROCEEDS — lines 5-6
# (66 + 1 = 67 chars > the cap → truncated again; the tuple's total
# is the slice's length, the document's is irrelevant).
slice_text = "\n".join(lines[4:6])
assert llm.requests[3][0][7]["content"] == (
"Document S/t.md:\ndate: 2024-06-15\nlines: 5-6 of 10\n"
+ slice_text[:cap]
+ "\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))
)
assert holder.read_truncations == [("S/t.md", cap, 339), ("S/t.md", cap, 67)]
assert holder.read_docs == [doc] # appended exactly once, still
assert holder.tool_calls == 2 # the refusal counted nothing
def test_plain_read_after_range_read_is_already_in_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 131 (task 02): a range read appends the doc to
``read_docs`` ONCE — so a later PLAIN read of the range-read
(never-truncated) document is ``ALREADY_IN_CONTEXT`` (the model has
committed to this doc; the ``lines:`` line told it what it holds)."""
doc = _doc("S", "p.md", "P", "a\nb\nc")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="c1",
name="read",
arguments={"path": "S/p.md", "offset": 1, "limit": 2},
)
],
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/p.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == (
"Document S/p.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb"
)
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
assert holder.read_docs == [doc] and holder.tool_calls == 1
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
holder = AgentHolder()
llm = ScriptedLLM(
+40
View File
@@ -127,6 +127,46 @@ def test_tool_event_shape_is_type_name_argument_only() -> None:
assert dumped["type"] == "tool" # default — call sites never spell it out
def test_tool_frame_range_read_carries_offset_and_limit() -> None:
"""Phase 131 (task 04): a range ``read``'s frame carries the line
range the model requested — the wire's assertion surface for the
owner's grep → read(offset/limit) dance (the story E2E pins the
REAL ints the mock parsed from the grep result)."""
frame = sse_event(
ChatToolEvent(
name="read", argument="S/p.md", offset=1500, limit=30
).model_dump()
)
assert frame == (
'data: {"type": "tool", "name": "read", "argument": "S/p.md", '
'"offset": 1500, "limit": 30}\n\n'
)
assert _payload(frame) == {
"type": "tool",
"name": "read",
"argument": "S/p.md",
"offset": 1500,
"limit": 30,
}
def test_tool_event_range_fields_absent_not_null_when_omitted() -> None:
"""Phase 131 (task 04): the ``offset`` / ``limit`` keys are ABSENT
(never ``null``) when omitted — a plain ``read`` (no range) and a
``grep`` (no range parameters at all) both serialize to the
byte-identical pre-phase shape (the omission pattern of
``ChatDoneEvent.answer_image``). A bad range VALUE never reaches
the frame at all: ``app.api.chat`` applies the whole-line-number
guard before building the event (the E2E suite pins the frame the
endpoint emits)."""
for dumped in (
ChatToolEvent(name="read", argument="S/p.md").model_dump(),
ChatToolEvent(name="read", argument="S/p.md", offset=None, limit=None).model_dump(),
ChatToolEvent(name="grep", argument="trap").model_dump(),
):
assert set(dumped.keys()) == {"type", "name", "argument"}, dumped
def test_retry_frame_serializes_exactly() -> None:
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
attempt: int, max_attempts: int}`` — a transient status the client's