diff --git a/.agents/phases/todo/131_read_line_ranges/00_phase.md b/.agents/phases/complete/131_read_line_ranges/00_phase.md similarity index 100% rename from .agents/phases/todo/131_read_line_ranges/00_phase.md rename to .agents/phases/complete/131_read_line_ranges/00_phase.md diff --git a/.agents/phases/todo/131_read_line_ranges/01_tool_surface.md b/.agents/phases/complete/131_read_line_ranges/01_tool_surface.md similarity index 100% rename from .agents/phases/todo/131_read_line_ranges/01_tool_surface.md rename to .agents/phases/complete/131_read_line_ranges/01_tool_surface.md diff --git a/.agents/phases/todo/131_read_line_ranges/02_range_read_execution.md b/.agents/phases/complete/131_read_line_ranges/02_range_read_execution.md similarity index 100% rename from .agents/phases/todo/131_read_line_ranges/02_range_read_execution.md rename to .agents/phases/complete/131_read_line_ranges/02_range_read_execution.md diff --git a/.agents/phases/todo/131_read_line_ranges/03_handbook_repro.md b/.agents/phases/complete/131_read_line_ranges/03_handbook_repro.md similarity index 100% rename from .agents/phases/todo/131_read_line_ranges/03_handbook_repro.md rename to .agents/phases/complete/131_read_line_ranges/03_handbook_repro.md diff --git a/.agents/phases/todo/131_read_line_ranges/04_mock_flow_e2e.md b/.agents/phases/complete/131_read_line_ranges/04_mock_flow_e2e.md similarity index 100% rename from .agents/phases/todo/131_read_line_ranges/04_mock_flow_e2e.md rename to .agents/phases/complete/131_read_line_ranges/04_mock_flow_e2e.md diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.md new file mode 100644 index 0000000..608839b --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.md @@ -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` diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.validate new file mode 100644 index 0000000..0ed807c --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.md new file mode 100644 index 0000000..d147536 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.md @@ -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 ``; 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` diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.validate new file mode 100644 index 0000000..90d9035 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__01_tool_surface.a1.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.md new file mode 100644 index 0000000..e6b9200 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.md @@ -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` diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.validate new file mode 100644 index 0000000..0e44f38 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a1.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.md new file mode 100644 index 0000000..293992a --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.md @@ -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` diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.validate new file mode 100644 index 0000000..0e44f38 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__02_range_read_execution.a2.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.md new file mode 100644 index 0000000..bea544e --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.md @@ -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`. diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.validate new file mode 100644 index 0000000..0ee02bc --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a1.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.md new file mode 100644 index 0000000..4ec20c3 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.md @@ -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`. diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.validate new file mode 100644 index 0000000..0ee02bc --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__03_handbook_repro.a2.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.md b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.md new file mode 100644 index 0000000..41e9fd3 --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.md @@ -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=, limit=30)` (the owner's exact dance); read result present → byte-stable answer `Found it at line N. ` (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. diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.validate b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.validate new file mode 100644 index 0000000..0ed807c --- /dev/null +++ b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a1.validate @@ -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 diff --git a/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a2.err b/.agents/reports/131_read_line_ranges/131_read_line_ranges__04_mock_flow_e2e.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example index b5423fd..021f70c 100644 --- a/.env.example +++ b/.env.example @@ -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 (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. diff --git a/README.md b/README.md index 73916d6..c2cc828 100644 --- a/README.md +++ b/README.md @@ -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 `` (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 diff --git a/app/api/chat.py b/app/api/chat.py index c0f67df..2dfee84 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -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: \\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): diff --git a/app/rag/agent.py b/app/rag/agent.py index 9a9e967..13217f7 100644 --- a/app/rag/agent.py +++ b/app/rag/agent.py @@ -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 ```` 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 diff --git a/app/schemas.py b/app/schemas.py index 087d5cc..210f2b5 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 0d9b46f..dae9066 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -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 ```` 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. `` — 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 ```` 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 ```` 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. `` 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 ```` + 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 "" 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 ```` 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 ```` block with the image- diff --git a/tests/e2e/test_read_line_ranges.py b/tests/e2e/test_read_line_ranges.py new file mode 100644 index 0000000..470beb2 --- /dev/null +++ b/tests/e2e/test_read_line_ranges.py @@ -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 ```` 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. `` + — 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 : …`` — 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 +```` 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 == 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 : …``) — 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 +# 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 == 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 diff --git a/tests/e2e/test_read_truncation_cap.py b/tests/e2e/test_read_truncation_cap.py index 833f9e5..c6ba979 100644 --- a/tests/e2e/test_read_truncation_cap.py +++ b/tests/e2e/test_read_truncation_cap.py @@ -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 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() diff --git a/tests/integration/test_agent_tools.py b/tests/integration/test_agent_tools.py index 4165a3d..d623df8 100644 --- a/tests/integration/test_agent_tools.py +++ b/tests/integration/test_agent_tools.py @@ -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"] diff --git a/tests/integration/test_read_line_ranges_handbook.py b/tests/integration/test_read_line_ranges_handbook.py new file mode 100644 index 0000000..457b0c7 --- /dev/null +++ b/tests/integration/test_read_line_ranges_handbook.py @@ -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))] diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index bd2b3d5..5d0e1ff 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -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 - # 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 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 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( diff --git a/tests/unit/test_sse_events.py b/tests/unit/test_sse_events.py index c339c33..cdb4c8e 100644 --- a/tests/unit/test_sse_events.py +++ b/tests/unit/test_sse_events.py @@ -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