diff --git a/.agents/phases/todo/141_read_range_dedupe_fix/00_phase.md b/.agents/phases/complete/141_read_range_dedupe_fix/00_phase.md similarity index 100% rename from .agents/phases/todo/141_read_range_dedupe_fix/00_phase.md rename to .agents/phases/complete/141_read_range_dedupe_fix/00_phase.md diff --git a/.agents/phases/todo/141_read_range_dedupe_fix/01_coverage_tracking.md b/.agents/phases/complete/141_read_range_dedupe_fix/01_coverage_tracking.md similarity index 100% rename from .agents/phases/todo/141_read_range_dedupe_fix/01_coverage_tracking.md rename to .agents/phases/complete/141_read_range_dedupe_fix/01_coverage_tracking.md diff --git a/.agents/phases/todo/141_read_range_dedupe_fix/02_dedupe_matrix_rework.md b/.agents/phases/complete/141_read_range_dedupe_fix/02_dedupe_matrix_rework.md similarity index 100% rename from .agents/phases/todo/141_read_range_dedupe_fix/02_dedupe_matrix_rework.md rename to .agents/phases/complete/141_read_range_dedupe_fix/02_dedupe_matrix_rework.md diff --git a/.agents/phases/todo/141_read_range_dedupe_fix/03_two_range_repro.md b/.agents/phases/complete/141_read_range_dedupe_fix/03_two_range_repro.md similarity index 100% rename from .agents/phases/todo/141_read_range_dedupe_fix/03_two_range_repro.md rename to .agents/phases/complete/141_read_range_dedupe_fix/03_two_range_repro.md diff --git a/.agents/phases/todo/141_read_range_dedupe_fix/04_e2e_mock_flow.md b/.agents/phases/complete/141_read_range_dedupe_fix/04_e2e_mock_flow.md similarity index 100% rename from .agents/phases/todo/141_read_range_dedupe_fix/04_e2e_mock_flow.md rename to .agents/phases/complete/141_read_range_dedupe_fix/04_e2e_mock_flow.md diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.md new file mode 100644 index 0000000..4a9f5ce --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.md @@ -0,0 +1,10 @@ +All gates green and verified. Final report: + +**Phase 141 final verification pass — complete** + +- **Verified (tasks 01–04 already in `complete/`):** `AgentHolder.read_coverage` span recording (all 4 read outcomes, refusals record nothing); coverage dedupe matrix (range read of uncovered lines proceeds, fully-covered re-read → `ALREADY_IN_CONTEXT` with a now-true claim, partial plain re-read → `REREAD_PARTIAL` naming merged spans, `REREAD_TRUNCATED` retired, fast path retired per task 02); `TOOLS_SECTION` re-read sentence re-cut to coverage-true form (prompt lock re-pinned); owner-scenario integration repro (pure + truncated variants) and mock `read two ranges` flow + dedicated E2E — all match the task specs exactly; no code defects found. +- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL 99% (5498 stmts, `app/rag/agent.py` 99%) — run twice, both green; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` → 2 passed in isolation; `uv run ruff check . && uv run pyright` → All checks passed / 0 errors. +- **Completion criteria:** (1) two different ranges both reach the model, re-covered re-read refused truthfully — ✅ (integration + E2E); (2) partial plain re-read gets the `offset`/`limit` teaching, false claim gone both directions — ✅; (3) plain-read + phase-131 result shapes byte-identical, existing pins pass unchanged — ✅; (4) all gate commands green, >90% — ✅; (5) no behavior change in completed phases (grep/ls, truncation notice, `read_docs`/`read_truncations` signals) — ✅ (whole project suite green). +- **Documentation:** README.md — fixed pre-existing phase-140 gap: `BOR_PDF_PICTORIAL` / `BOR_PDF_PICTORIAL_MIN_CHARS` were in `.env.example`/Settings but undocumented (README claims every `.env.example` var is covered); added the pictorial-PDF-pass bullet to the PDF section + two config-reference rows. AGENTS.md — verified consistent (rules, quick-ref commands, phase refs all check out) — no changes needed. +- **Notable:** overview's "fast path survives for holder-state rows" note is superseded by task 02's authoritative decision (every dedupe row resolves — "full" needs the true line count); implementation follows task 02. No phase files touched; changes left in the working tree for the harness commit. +- **Next pending phase:** `142_kb_overview_freshness`. diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.validate new file mode 100644 index 0000000..c12b5f1 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__00_phase.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 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 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 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/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.md new file mode 100644 index 0000000..cc4a0d6 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.md @@ -0,0 +1,20 @@ +Task 01 is complete. All changes are in the working tree (no commits, per harness protocol). + +**Report** + +**Implemented** (phase 141, task 01 — coverage tracking, recording only, no dedupe behavior change): +- `AgentHolder.read_coverage: list[tuple[str, str, int, int]]` — one `(source, path, first_line, last_line)` span per successful read, in read order, keyed by the RESOLVED pair; documented on the dataclass docstring (refusals record nothing; `read_docs`/`read_truncations` keep their exact meaning). +- Read branch records the span at all four successful outcomes: plain at-cap `(1, total_lines)`; truncated plain `(1, len(content[:cap].splitlines()))`; range at-cap `(first, end)` (clamped extent); truncated range `(first, min(end, first - 1 + len(slice[:cap].splitlines())))`. +- 14 new unit tests in `tests/unit/test_agent.py`: the four record sites (incl. both mid-line and line-boundary cut sub-cases), span accumulation in read order, resolved-pair keying (nested path), and all six refusal categories recording nothing (bad range args, unknown doc, offset-past-end, plain re-read full, plain re-read truncated, range re-read full). + +**Test / lint / coverage** (exact commands): +- `uv run pytest --cov=app --cov-report=term-missing` → 3338 passed, 7 skipped, exit 0; TOTAL `app/` coverage **99%** (>90% gate); `app/rag/agent.py` 99% (only pre-existing uncovered line 1026 — my new lines all covered). +- `uv run ruff check .` → All checks passed. +- `uv run pyright` → 0 errors, 0 warnings. +- All pre-existing pins (phase-95/106/118/122/131 dedupe matrix, result shapes, `read_docs`/`read_truncations`, SSE frames) pass unchanged — no behavior change. + +**Docs:** no doc impact (internal record-only state; no setup/config/API/behavior change) — README/AGENTS.md untouched. + +**Decisions:** the cut-line counting rule follows the task's explicit formulas verbatim — `splitlines()` on the ACTUAL cut string (a cap cutting mid-line ends the span at that line's visible prefix, counted; boundary cuts on a newline count the complete line), pinned in both sub-case tests so task 02's `covered()` builds on pinned semantics. + +**Next pending task:** `.agents/phases/todo/141_read_range_dedupe_fix/02_dedupe_matrix_rework.md`. diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.validate new file mode 100644 index 0000000..3eb060f --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__01_coverage_tracking.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +.......sss...ss......................................................... [ 55%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +.................................................................ss..... [ 96%] +........................................................................ [ 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 417 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5493 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/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.md new file mode 100644 index 0000000..237ccc5 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.md @@ -0,0 +1,17 @@ +Task 02 is complete — all gates green. + +**Implemented** +- `app/rag/agent.py`: pure coverage helpers `covered_spans` (merges overlapping/adjacent/contained spans, resolved-pair keyed), `fully_covered` (inclusive per-line containment), `format_covered_spans` (`a–b` en-dash rendering, single line bare `a`); read-branch dedupe reworked onto the coverage matrix — raw-arg fast path RETIRED (every dedupe row resolves first), range read proceeds unless the requested span is fully covered (owner's stuck scenario fixed), fully-covered re-reads → byte-identical `ALREADY_IN_CONTEXT`, plain re-read of a partial doc → NEW `REREAD_PARTIAL` (covered span(s) + true total); `REREAD_TRUNCATED` deleted; docstrings updated. +- `app/rag/prompts.py`: `TOOLS_SECTION` re-read sentence re-cut to the coverage-true form (read-IN-FULL vs partial → `offset`/`limit`). +- Tests: re-pinned the changed matrix rows (incl. row 4 — a truncated 1–14 read now truthfully refuses range 5–7 and proceeds on 15–16; fast-path lookup-count pins → 2 lookups), added pure-helper edge tests (adjacent/overlap/contained/disjoint/gap-crossing) and the owner-bug flow (range A → range B proceeds → re-read A refused); prompt-lock sha/len re-cut (prefix/suffix survived); phase-131 handbook integration test re-pinned to `REREAD_PARTIAL`. + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → 3350 passed, 2 skipped (missing untracked PDF fixtures), `app/` coverage **99%** (agent.py 99%, prompts.py 100%) +- `uv run ruff check .` clean; `uv run pyright` → 0 errors +- Completed-phase E2E verified in isolation: `test_read_range_tool_lines.py` 3 passed; `test_read_line_ranges.py` + `test_read_truncation_cap.py` 5 passed; handbook integration 2 passed (fixture tar extracted, gitignored) + +**Docs:** no doc impact (no setup/command/config/API changes; README/AGENTS.md carry no dedupe contract). + +**Decisions:** row-4 and `test_plain_read_after_range_read` re-pins are the documented matrix behavior change (the false full-text claim removed in both directions); handbook fixture left extracted (untracked, gitignored owner workflow). + +**Next pending task:** `.agents/phases/todo/141_read_range_dedupe_fix/03_two_range_repro.md` diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.validate new file mode 100644 index 0000000..5b6ff74 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +................s....s.................................................. [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 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 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 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/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.md new file mode 100644 index 0000000..40a7fca --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.md @@ -0,0 +1,10 @@ +**3352 passed, 0 skipped** — the entire project is green. The tree state matches the completed task 02 work; no further fixes were needed (the previous run's work was intact; the rejection was a child-run report cutoff, not a test failure). + +**Report** + +- Task 02 (coverage-based dedupe matrix) verified intact and green in the working tree: `covered_spans`/`fully_covered`/`format_covered_spans` helpers, fast-path retirement (every dedupe row resolves), range reads proceed unless fully covered (owner's bug fixed), fully-covered re-reads → byte-identical `ALREADY_IN_CONTEXT`, partial plain re-reads → new `REREAD_PARTIAL` (line-based span rendering), `REREAD_TRUNCATED` retired, `TOOLS_SECTION` re-read sentence re-cut to the coverage-true form with prompt-lock anchors re-cut. +- Re-ran full gates this session: `uv run pytest --cov=app --cov-report=term-missing` → **3352 passed, 0 failed, 0 skipped**; `app/` coverage **99%** (agent.py 99%, prompts.py 100%); `uv run ruff check .` clean; `uv run pyright` → 0 errors. +- Fixed the 2 pre-existing skips: extracted the gitignored owner fixtures (`players_handbook.tar`, `pdf_horse_test.tar` from `test_sources/` — untracked workflow, never committed) so the 4 fixture-gated tests run and pass. +- Docs: no doc impact — README/AGENTS.md carry no dedupe/refusal contract (not this phase's final pass, so no project-wide doc check required). +- Notable decisions: re-pinned phase-131 matrix rows per the documented behavior change (truncated 1–14 read now truthfully refuses range 5–7 and proceeds on 15–16; fast-path pins now expect 2 lookups); completed-phase E2E suites verified green in isolation last session (`test_read_range_tool_lines.py` 3 passed; `test_read_line_ranges.py` + `test_read_truncation_cap.py` 5 passed) with no code changes since. +- Next pending task: `.agents/phases/todo/141_read_range_dedupe_fix/03_two_range_repro.md` diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.validate new file mode 100644 index 0000000..ff05cbe --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__02_dedupe_matrix_rework.a2.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 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 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 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/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.md new file mode 100644 index 0000000..d237354 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.md @@ -0,0 +1,12 @@ +All green. Task 03 is complete. + +**Report** + +- **Integration** (`tests/integration/test_read_range_dedupe_two_ranges.py`, new): the owner's stuck scenario end-to-end through `run_agent` on the real DB, ONE generated 200-line doc (markers far apart; content generated in-test, house KB pattern, short-lived sessions per tool call, read-echo fake LLM): + - *Pure dedupe* (doc under cap): read range A (1–40) → range B (140–169) **proceeds** (pre-phase false `ALREADY_IN_CONTEXT` is gone) → exact re-read of range A refused with `ALREADY_IN_CONTEXT` → answer echoes **both** marker lines; holder bookkeeping (read_docs once, 2 tool_calls, coverage spans) pinned. + - *Truncated variant* (doc over derived cap): plain read truncates (covers only its cut span, mid-line cut) → same range-B read proceeds via coverage → plain re-read gets `REREAD_PARTIAL` naming **both** covered spans (`1–31, 140–169` — multi-span rendering proven end-to-end) → echo answer with both markers. +- **Unit** (`tests/unit/test_agent.py`): added the scenario-shaped adjacent-span edge — two adjacent range reads covering lines 1–100 of a 200-line doc → plain re-read gets `REREAD_PARTIAL` naming merged `1–100`, and `read(offset=101)` proceeds with 101–200 (plus a module-docstring clause). +- **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → 3355 passed, `app/` **99%** (>90% gate); `uv run ruff check .` clean; `uv run pyright` 0 errors; new files green in isolation and alongside the phase-131/phase-70 integration suites (no state leakage). +- **Docs:** no doc impact (test-only task; README/AGENTS.md verified accurate). +- **Deviations:** the task's "marker A near line ~50" hint is unsatisfiable with the pinned round shape (range A = 1–40) + pinned echo assertion (answer must echo marker A; the model can only echo what reached it) — marker A placed at line 30, marker B at exactly 150; the truncated variant's cap is derived from the generated content (marker lines are longer than filler lines, so a literal char cap would drift). No existing test was modified — the repro is additive. +- **Next pending task:** `.agents/phases/todo/141_read_range_dedupe_fix/04_e2e_mock_flow.md` (mock `read two ranges` flow + dedicated E2E file). diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.validate new file mode 100644 index 0000000..c12b5f1 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__03_two_range_repro.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 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 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 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/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.err b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.md b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.md new file mode 100644 index 0000000..8bb3b8c --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.md @@ -0,0 +1,19 @@ +All green. Task 04 complete. + +**Implemented** +- `tests/e2e/mock_llm.py`: new scripted flow `TWO_RANGES_TRIGGER` (`read two ranges`) — house style (question-carried-call convention, module docstring entry, stateless count-based classifier, dispatch before the plain `TOOLS_TRIGGER` flow): `read(offset=1,limit=100)` → second `read(offset=101,limit=100)` on the same doc (keyed on asserted `lines: 1-100 of …` / `lines: 101-…` extent headers) → `"Both parts read. "`; broken premise (a refusal — the pre-phase bug — or unkeyed header) lands as a verbatim `Premise broke:` echo so suites fail loudly. Also fixed the now-stale phase-139 docstring NOTE (small-cap premise retired by coverage tracking — doc-only, behavior byte-identical). +- `tests/e2e/test_read_range_dedupe.py`: dedicated mock-only Playwright suite, house fixtures (own port 8156, truncate/re-import per module, SSE hook): ONE 200-line txt fixture — `TWO_RANGES_B_LINE` at line 101 (range B's FIRST line, so the 80-char echo carries it), `TWO_RANGES_A_LINE` at line 50, `LINE_MARKER` at line 30; default read cap (nothing truncates — that's the point). + - Test 1: exactly the two `read` tool frames (`1/100`, `101/100`), zero `tool_result` frames, `done` non-deflected + doc cited, answer carries `Both parts read.` + the B marker text, no `ALREADY_IN_CONTEXT`/refusal surface. + - Test 2: phase-131 `read the lines` flow's existing expectations (grep → range read → grounded echo) unchanged on the coverage-tracked server; the still-refused row stays unit-pinned (tasks 02/03). + +**Results** +- `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` → 2 passed, in isolation (ran twice) +- Regression in isolation: `test_read_line_ranges.py` 2 passed · `test_read_range_tool_lines.py` 3 passed · `test_read_truncation_cap.py` 3 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 3355 passed, `app/` coverage 99% (>90%) +- `uv run ruff check .` → all checks passed · `uv run pyright` → 0 errors, 0 warnings + +**Docs:** no README/AGENTS.md impact (no setup/commands/env/API changes; the flow is documented in the mock's module docstring per house convention). + +**Decisions:** B marker placed at line 101 (range B start) rather than "~150" — the task's binding "answer echoes the second range's marker line" only holds with the existing read-echo shape (first 80 chars of the slice) when the marker sits at the range's start; marker lines get two contextual neighbor lines so each marker chunk's cosine clears the mock-calibrated 0.15 lexical floor (Q1 0.23 / Q2 0.19 — deterministic). + +**Next pending task:** none — task 04 is the last of phase 141 (phase complete, ready for the harness's final pass + atomic commit). diff --git a/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.validate b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.validate new file mode 100644 index 0000000..c12b5f1 --- /dev/null +++ b/.agents/reports/141_read_range_dedupe_fix/141_read_range_dedupe_fix__04_e2e_mock_flow.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 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 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 259 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 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 56 0 100% +app/rag/importer.py 705 9 99% +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 124 0 100% +app/rag/prompts.py 154 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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 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/README.md b/README.md index 4ef31c4..a987920 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,26 @@ uploaded archives): descriptions (a failed page gets a `page N — description unavailable` placeholder — fail-soft, the doc still lands). Clean text layers never burn a fallback vision call. The run counts the affected PDFs - on the import summary line (`pdfs_screenshotted=N`). + on the import summary line (`pdfs_screenshotted=N`). A garbage-text + PDF takes this fallback instead of the pictorial pass below — the + precedence is locked, never double vision. +- **Pictorial pages get vision-described (phase 140, on by default).** + For a PDF whose text layer is usable but thin — the IKEA + assembly-instruction class (20 of 24 pages: 4–73 chars of markers + around vector-drawn pictograms) — every page whose STRIPPED text is + under `BOR_PDF_PICTORIAL_MIN_CHARS` + characters (default `100`; a deterministic per-page threshold, no LLM + in the gate) is rendered to a PNG and described by the chat model, + and the description appends to that page's content as + `[Page N illustration: …]` — so the embeddings carry what the thin + text layer couldn't, and a question about the illustrations hits. + NO page cap: every candidate page is described (A21 — the LLM calls + are free; a slower sync is acceptable). A PDF with zero candidate + pages burns no render or vision call — its indexed content is + byte-identical to pre-phase-140. The run counts the affected PDFs and + pages on the import summary line (`pdfs_pictorial=N` + `pdf_pictorial_pages=N`). `BOR_PDF_PICTORIAL=0` turns the whole pass + off (byte-identical pre-phase-140 content). - **Served as text.** The document viewer renders the extracted text — never the binary PDF. @@ -749,6 +768,8 @@ to `.env` and adjust. | Env | Default | Meaning | |-----|---------|---------| | `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Add the `pdf` token to make PDFs first-class, retrievable documents (phase 126, opt-in). Images are **not** added here — see `BOR_IMAGES` | +| `BOR_PDF_PICTORIAL` | `1` | Master switch for the pictorial PDF pass (phase 140, on by default): a usable-text PDF's sparse pages (stripped text under `BOR_PDF_PICTORIAL_MIN_CHARS`) are rendered + vision-described into the indexed content (`[Page N illustration: …]`); `0` ⇒ content byte-identical to pre-phase-140 (no render, no vision call) | +| `BOR_PDF_PICTORIAL_MIN_CHARS` | `100` | Sparseness bar for the pictorial pass (phase 140): a page whose STRIPPED text is STRICTLY shorter than this many chars is a candidate (a page with exactly this many is not); `0` = every page (A21); a negative value fails startup loudly | | `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** | | `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled | | `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked | diff --git a/app/rag/agent.py b/app/rag/agent.py index 13217f7..58b74f4 100644 --- a/app/rag/agent.py +++ b/app/rag/agent.py @@ -109,13 +109,21 @@ task 04): 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 + teaching. The dedupe is COVERAGE-based (phase 141, task 02 — + :data:`AgentHolder.read_coverage` records the line span each + successful read actually put in the model's context, and the merged + spans decide): a range read PROCEEDS unless the requested lines are + FULLY covered by earlier reads (the owner's stuck-scenario fix — + two DIFFERENT ranges of a document both reach the model, and the + phase-131 truncated-then-range row is covered by the state — a + truncated first read covers only its cut span); a re-read of + FULLY-covered lines (a read-in-full document, or an earlier span) + gets :data:`ALREADY_IN_CONTEXT` — the claim is then TRUE (the named + lines ARE in the prompt); a PLAIN re-read of a PARTIALLY-covered + document gets the :data:`REREAD_PARTIAL` teaching (the covered + span(s) named in line form, read the rest with ``offset``/ + ``limit``) instead of the (wrong) full-text claim. **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 @@ -157,20 +165,28 @@ task 04): :data:`NOT_A_FOLDER`, the drill-down teaching with the argument echoed and the deepest existing ancestor's direct subfolders listed, so the model self-corrects in the next round; a document - already READ into full-text context (phase 118: the suggested - seeds are summary blocks in the prompt, not full text — only an - already-read document is refused) → + READ into FULL-text context — a re-read whose lines are FULLY + covered by earlier reads (phase 141: the COVERAGE state, not the + pair set, decides — the raw-arg fast path is retired, every dedupe + row resolves the document first; phase 118: the suggested seeds + are summary blocks in the prompt, not full text, so a first read + of a suggested document still proceeds) → :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); 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 + the loop instead of inviting a repeat; phase 141: the claim is + then true — the named lines ARE in the prompt); a ``read`` whose + optional ``offset``/``limit`` is not a whole line number ≥ 1 + (phase 131) → :data:`BAD_RANGE_ARGS` (the bad values echoed; fires + FIRST — before any dedupe or resolve); a range ``read`` whose + ``offset`` is past the document's end (phase 131) → + :data:`OFFSET_PAST_END` (the true line count named; fires after + the resolve, before the coverage matrix — an offset past the end + is refused regardless of coverage); a PLAIN re-read of a document + only PARTIALLY covered by earlier reads (a truncated read, or a + range read — phase 141) → :data:`REREAD_PARTIAL` (the covered + span(s) named, 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 @@ -584,24 +600,33 @@ BAD_RANGE_ARGS = ( "(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 plain re-read of a PARTIALLY-covered document (phase 141, +#: task 02 — the dedupe matrix's middle row, generalizing and +#: RETIRING the phase-131 ``REREAD_TRUNCATED`` line, whose char-based +#: ``{shown}`` copy is replaced by the line-based truth): earlier +#: reads put SOME — not all — of the document's lines in the +#: model's context (a truncated read covers its cut span, a range +#: read covers its own span — :data:`AgentHolder.read_coverage` is +#: the state this row decides on), so the rest is NOT in its +#: context. The line names the covered span(s) in line form +#: (``{covered}`` — the merged spans rendered by +#: :func:`format_covered_spans`, comma-joined ``a–b`` pairs, a single +#: line bare ``a``) and points at the working workflow — ``grep`` +#: reports line numbers, then ``read`` that part with ``offset`` / +#: ``limit`` — instead of the dead end (the plain +#: :data:`ALREADY_IN_CONTEXT` line, which claimed the FULL text was +#: in the prompt — false for a partial document). ``{source}`` / +#: ``{path}`` are the RESOLVED identity; ``{total}`` is the +#: document's true line count (``len(content.splitlines())``) — both +#: plain, no ``!r``. A refusal: no re-emit, ``tool_calls`` NOT +#: incremented, consumes a round; fires after the resolve (the true +#: line count only the resolve knows — the raw-arg fast path is +#: retired). +REREAD_PARTIAL = ( + "You have lines {covered} of {source}/{path} in your context " + "({total} lines total). 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` (number of lines)." ) #: The range-``read`` offset past the end (phase 131, task 02): the @@ -1359,6 +1384,55 @@ def _read_range_args( return True, "", offset, limit +def covered_spans( + records: Sequence[tuple[str, str, int, int]], source: str, path: str +) -> list[tuple[int, int]]: + """Merged coverage spans for ONE document (phase 141, task 02). + + Selects the ``read_coverage`` records keyed by the RESOLVED + ``(source, path)`` pair (records for other documents are ignored) + and returns their inclusive line spans, MERGED: overlapping spans + collapse into their union (a span contained in another is + absorbed) and ADJACENT spans join (``(1, 10) + (11, 20)`` → + ``(1, 20)`` — adjacency counts as continuous coverage), sorted by + first line. Pure over holder state — no DB. + """ + merged: list[list[int]] = [] + for first, last in sorted( + (a, b) for s, p, a, b in records if s == source and p == path + ): + if merged and first <= merged[-1][1] + 1: + merged[-1][1] = max(merged[-1][1], last) + else: + merged.append([first, last]) + return [(first, last) for first, last in merged] + + +def fully_covered( + spans: Sequence[tuple[int, int]], first: int, last: int +) -> bool: + """Whether the requested ``(first, last)`` span sits inside the + merged coverage union (phase 141, task 02) — per line, inclusive: + one merged span must start at or before ``first`` and end at or + after ``last``. Pure — no DB. + """ + return any( + span_first <= first and last <= span_last for span_first, span_last in spans + ) + + +def format_covered_spans(spans: Sequence[tuple[int, int]]) -> str: + """Render merged coverage spans for the :data:`REREAD_PARTIAL` + ``{covered}`` field (phase 141, task 02): comma-joined ``a–b`` + pairs (the en dash — one span → the bare ``a–b``; a single line → + the bare ``a``), in span order. Pure — no DB. + """ + return ", ".join( + f"{first}" if first == last else f"{first}–{last}" + for first, last in spans + ) + + def grep_document(content: str, pattern: str) -> list[tuple[int, str]]: """Every line of *content* that contains *pattern*, in file order. @@ -1408,6 +1482,29 @@ class AgentHolder: nothing). Read by ``app.api.chat`` after the stream for the done frame's ``answer_image`` field (task 04) — the ``read_docs`` precedent (an API-layer read-after-stream signal). + ``read_coverage`` (phase 141, task 01): one ``(source, path, + first_line, last_line)`` record per covered span — INCLUSIVE line + numbers over the document's ``content.splitlines()``, in read + order, keyed by the RESOLVED ``(source, path)`` pair (the resolved + document's own identity — the same pair ``read_docs`` keys on; by + the :func:`_resolve_path` first-slash contract the raw argument's + split and the resolved pair agree). One span per SUCCESSFUL + ``read`` — a refused read records NOTHING (the refusal style of + every refusal: it counts in nothing): a plain read at or under the + cap covers ``(1, total_lines)``; a truncated plain read covers + line 1 through the count from ``splitlines()`` on the ACTUAL cut + string, ``content[:cap]`` (the lines the model actually saw; the + char cap is never re-derived into lines arithmetically); a range + read covers ``(first, end)`` — the TRUE extent the ``lines: + {first}-{end} of {total}`` header reports (an overshooting + ``limit`` is already clamped into ``end``); a truncated range + read covers the same cut count into the slice — ``first - 1 + + len(slice[:cap].splitlines())``, clamped to ``end``. The + reworked dedupe matrix (phase 141, task 02) decides on these + spans; ``read_docs`` / ``read_truncations`` keep + their EXACT current meaning (API-layer signal, SSE matching, + per-turn log) — this list is NEW state beside them, not a + replacement. """ read_docs: list[Document] = field(default_factory=list) @@ -1415,6 +1512,7 @@ class AgentHolder: scaffold_stripped: int = 0 read_truncations: list[tuple[str, int, int]] = field(default_factory=list) shown_images: list[Document] = field(default_factory=list) + read_coverage: list[tuple[str, str, int, int]] = field(default_factory=list) def _execute_tool( @@ -1442,15 +1540,30 @@ def _execute_tool( ``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: + FIRST — before any dedupe or resolve), then the COVERAGE dedupe + matrix (phase 141, task 02 — the raw-arg fast path is RETIRED: + whether a span is "full" needs the document's TRUE line count, + which only the resolve knows, so EVERY dedupe row resolves the + document first — one cheap indexed lookup; the pre-phase-141 bug + a false refusal dead-ended a whole stuck turn, and that cost is + what forces the trade): a range read PROCEEDS unless the + requested lines are FULLY covered by earlier reads (the owner's + stuck-scenario fix — includes the phase-131 truncated-then-range + row, a truncated first read covering only its cut span), a + re-read of fully-covered lines gets :data:`ALREADY_IN_CONTEXT` + (the claim is then true), a plain re-read of a PARTIALLY-covered + one gets :data:`REREAD_PARTIAL` (the covered span(s) named), an + ``offset`` past the end → :data:`OFFSET_PAST_END` (after the + resolve, before the matrix), 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`` + length). Phase 141 (task 01): every SUCCESSFUL read also appends + its covered span — the RESOLVED pair + the inclusive line extent + of what actually reached the model (per the four outcomes above) — + to ``holder.read_coverage`` (the NEW state the reworked dedupe + matrix, task 02, decides on); a refused read records nothing, and + ``read_docs`` / ``read_truncations`` keep their exact current + meaning. Document targets are combined ``source/path`` strings, resolved by :func:`_resolve_path` (the canonical identity, phase 70). """ @@ -1531,42 +1644,25 @@ def _execute_tool( # 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. + # Phase 118 (A6): ``holder.read_docs`` stays the read-once + # bookkeeping for the API layer (``done.sources`` / the + # per-turn log's ``sources=``) — the DEDUPE no longer reads + # it: phase 141 (task 02) decides from the COVERAGE state + # (below). The ``seed_docs`` are SUMMARY blocks in the prompt, + # not full text, so a FIRST read of a suggested document still + # adds its full text through the path below. known = {(doc.source, doc.path) for doc in holder.read_docs} - # 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 + # Phase 141 (task 02): the raw-arg fast path is RETIRED — + # whether a span is "full" needs the document's TRUE line + # count, which only the resolve knows, so EVERY dedupe row + # resolves the document first (one cheap indexed lookup; the + # fast path's only saving was that one query, and the + # pre-phase-141 bug — a false refusal dead-ending a whole + # stuck turn — is what forces the trade). A fully-covered + # plain re-read — the only row the old fast path handled — + # still refuses with the byte-identical + # :data:`ALREADY_IN_CONTEXT`; its implementation just goes + # through the resolve. doc, _source, _path = _resolve_path(db, arg) if doc is None: # Echo the argument as passed — the model sees its own form @@ -1574,38 +1670,61 @@ 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) - # 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) + # The range read's TRUE extent (``end`` clamps an overshooting + # ``limit`` to the last line; ``offset`` without ``limit`` + # reads to the end) — computed before the matrix (the range + # coverage row needs it) and reused by the slice shaping + # below. + end = total_lines if limit is None else min(first + limit - 1, total_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. + # The offset names a line past the end — teaching refusal + # (fires after the resolve, BEFORE the coverage matrix — + # an offset past the end is refused regardless of + # coverage): 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 COVERAGE dedupe matrix (phase 141, task 02 — the + # rework of the phase-131 pair-set dedupe): the merged spans + # for this RESOLVED document decide. + # plain, full document covered → :data:`ALREADY_IN_CONTEXT` + # plain, partial → :data:`REREAD_PARTIAL` + # (the covered span(s) named — the REREAD_TRUNCATED role, + # generalized: line-based truth) + # range, requested span fully → :data:`ALREADY_IN_CONTEXT` + # covered (the named lines ARE in the prompt — the claim is + # true) + # range, not fully covered → PROCEEDS (the owner's + # stuck-scenario fix — includes the phase-131 + # truncated-then-range row: a truncated first read covers + # only its cut span) + spans = covered_spans(holder.read_coverage, doc.source, doc.path) + if not ranged: + if fully_covered(spans, 1, total_lines): + return ALREADY_IN_CONTEXT + if spans: + return REREAD_PARTIAL.format( + covered=format_covered_spans(spans), + source=doc.source, + path=doc.path, + total=total_lines, + ) + elif fully_covered(spans, first, end): + return ALREADY_IN_CONTEXT # 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. + # appends; a PROCEEDING re-read of an earlier partial read of + # the same document (the owner's second range) does not + # re-append. if (doc.source, doc.path) not in known: holder.read_docs.append(doc) holder.tool_calls += 1 @@ -1636,6 +1755,18 @@ def _execute_tool( holder.read_truncations.append( (cast("str", raw_path), cap, len(doc.content)) ) + # Phase 141 (task 01): coverage tracking — the lines + # this cut actually put in the model's context: from + # line 1 to the last line visible in the ACTUAL cut + # string (``splitlines()`` on ``content[:cap]`` — the + # counted-from-truth rule; a cap that cuts mid-line + # ends the span at that line's visible prefix; the + # char cap is never re-derived into lines + # arithmetically). Task 02's dedupe matrix decides on + # these spans — task 01 only records. + holder.read_coverage.append( + (doc.source, doc.path, 1, len(doc.content[:cap].splitlines())) + ) # Phase 106 (D5): the date rides every document the # model sees — the ``read`` result's SECOND line; the # FIRST line stays ``Document {source}/{path}:`` @@ -1652,8 +1783,12 @@ def _execute_tool( # 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. + # no TRUNCATION holder entry, no ToolResultPiece) — and, + # phase 122, the image-document marker line for image + # docs only. + # Phase 141 (task 01): a plain read at or under the cap + # covers the WHOLE document — lines 1..total_lines. + holder.read_coverage.append((doc.source, doc.path, 1, total_lines)) return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" @@ -1661,13 +1796,13 @@ def _execute_tool( 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 + # the TRUE extent on the third line (``end`` — computed + # before the matrix above: it 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 @@ -1678,6 +1813,19 @@ def _execute_tool( holder.read_truncations.append( (cast("str", raw_path), cap, len(slice_text)) ) + # Phase 141 (task 01): the truncated range read covers + # from ``first`` to the last line visible in the cut + # SLICE — the same counted-from-truth rule as the plain + # read above (``splitlines()`` on the actual cut string), + # clamped to ``end``. + holder.read_coverage.append( + ( + doc.source, + doc.path, + first, + min(end, first - 1 + len(slice_text[:cap].splitlines())), + ) + ) return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" @@ -1686,6 +1834,11 @@ def _execute_tool( f"{TRUNCATION_MARKER}\n" f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))}" ) + # Phase 141 (task 01): the range read covers exactly the TRUE + # extent the ``lines:`` header reports (``end`` already + # clamped an overshooting ``limit`` — the span never goes + # past the document's last line). + holder.read_coverage.append((doc.source, doc.path, first, end)) return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" @@ -1820,7 +1973,9 @@ async def run_agent( ``tool`` frame and BEFORE the next model round (the API layer turns it into an SSE ``tool_result`` frame, task 02). After the loop finishes, *holder* carries the read documents, the executed tool-call count - (re-lists included), and the ``read_truncations`` signal list. + (re-lists included), the ``read_truncations`` signal list, and the + ``read_coverage`` spans (phase 141, task 01 — the lines each + successful read actually put in the model's context). History (phase 74, TODO L4): *history* is the client's prior turns already mapped to model messages by @@ -1875,11 +2030,15 @@ async def run_agent( ``seed_docs`` are the suggested documents whose SUMMARY blocks the caller put in the *system_prompt* (phase 118: the retrieval seeds summaries, never full texts); reading one of them ADDS its full - text to the context through the ordinary ``read`` path — only a - document ALREADY READ is rejected with :data:`ALREADY_IN_CONTEXT` - (the phase-72 teaching line — answer from the text already in the - prompt); the rejection counts in nothing, but it still consumes a - round. + text to the context through the ordinary ``read`` path — a re-read + whose lines are FULLY covered by earlier reads is rejected with + :data:`ALREADY_IN_CONTEXT` (the phase-72 teaching line — answer + from the text already in the prompt; phase 141: the coverage + state decides, so the claim is then true, and a re-read of a + PARTIALLY-covered document gets :data:`REREAD_PARTIAL` instead — + the covered span(s) named, read the rest with ``offset``/ + ``limit``); the rejection counts in nothing, but it still consumes + a round. DB sessions (SEC-14-04): *db_factory* is a callable that returns a new :class:`sqlalchemy.orm.Session` (e.g. ``lambda: SessionLocal()``). diff --git a/app/rag/prompts.py b/app/rag/prompts.py index 7d602b7..842da03 100644 --- a/app/rag/prompts.py +++ b/app/rag/prompts.py @@ -197,10 +197,15 @@ SUGGEST_INTRO = ( #: phase-118 summary-seed contract (the ```` section holds #: SUMMARIES — a suggested document's full text is not in the prompt #: until ``read`` adds it; do not re-read a document you have already -#: read — answer from the text already in the prompt; if the user -#: asks to open or read a suggested document, ``read`` it — that is -#: the point of the section — the phase-72 do-not-read rule and the -#: reverted ```` anchor are retired with the +#: read IN FULL — answer from the text already in the prompt — a +#: PARTIAL document (a range read, or a truncated read) is not +#: complete in the context until read all: read the other parts with +#: ``offset`` / ``limit`` (phase 141, task 02 — the re-read sentence +#: re-cut to the coverage-true form; the mock keys off the ```` +#: marker's presence, not the wording, so it is untouched); if the +#: user asks to open or read a suggested document, ``read`` it — that +#: is the point of the section — the phase-72 do-not-read rule and +#: the reverted ```` anchor are retired with the #: full-text seeds); #: the one-call rule names the consequence (a batched second call is #: discarded — runs only the first); the never-repeat rule says why @@ -232,8 +237,11 @@ TOOLS_SECTION: str = ( "summary blocks — adding its full content to your context. The " " section holds SUMMARIES — the full text of a " "suggested document is not in your prompt until you `read` it. Do " - "not re-read a document you have already read — its full text is " - "already in your prompt; answer directly from it. If the user " + "not re-read a document you have already read in full — its full " + "text is in your prompt, answer from it. If you have read only " + "part of a document (a range read, or a truncated read), read the " + "other parts with `offset` and `limit` — the document is not " + "complete in your context until you have read it all. If the user " "asks you to open or read a suggested document, `read` it — that " "is the point of the section. For `read`, a bare document path " "(without the source name) will not resolve. Very large documents " diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 4d5ccb7..a68913f 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -644,17 +644,76 @@ Implements just enough of the aipi surface: N`` extent) skipped, first 80 chars (the phase-94 read-echo convention — BOTH ranges reached the model's context). NOTE: for the second read to PROCEED (not be refused - ``ALREADY_IN_CONTEXT``) the document must already be in the - agent's truncated set — the story suite boots the app under - test with a ``BOR_READ_MAX_CHARS`` small enough that the - FIRST (20-line) range slice truncates, which is exactly the - large-document premise of the owner's confusion. + ``ALREADY_IN_CONTEXT``) the document was required to already + be in the agent's truncated set — the story suite boots the + app under test with a ``BOR_READ_MAX_CHARS`` small enough + that the FIRST (20-line) range slice truncates, which is + exactly the large-document premise of the owner's confusion. + Phase 141 RETIRED that premise: the coverage-tracked dedupe + proceeds with a second range read whenever its lines are not + fully covered — the suite's small cap stays as the + large-document premise, but is no longer LOAD-BEARING for the + second read to proceed. Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — the phase-71/72/94/118/131 ordering convention; the trigger needs the ```` section, so deflected turns never hit it); verified 2026-09-30: no existing E2E question or fixture file contains the phrase, so every other suite is unaffected. + - user message containing ``read two ranges`` + (``TWO_RANGES_TRIGGER``, phase 141 task 04 — the read + range-dedupe fix's dedicated story suite + ``tests/e2e/test_read_range_dedupe.py``) **and** the system + prompt carries the ```` section -> the owner's stuck + scenario (TODO.md L3), end to end: the model range-reads ONE + document in TWO different 100-line ranges — pre-phase the + second read was refused ``ALREADY_IN_CONTEXT`` ("the document + is already in my context, but I haven't actually seen the full + text") while ONLY the first slice was in the prompt; the + coverage-tracked server proceeds with it, and the answer + echoes the SECOND range's content (the marker line lives in + range B, so an echo of it proves the slice reached the model). + The question carries its own target after the colon (``read + two ranges: read source/path`` — the question-carried-call + convention of ``READ_CAP_TRIGGER`` / ``SAME_FILE_TRIGGER``), + and the step is then discriminated statelessly by the tool + results in the messages (the house count pattern — streaming + only): + * request 1 (``tools`` offered, no tool results yet): a + ``tool_calls`` delta — ``read`` on the parsed target with + ``offset`` = ``TWO_RANGES_FIRST_OFFSET`` (1) and ``limit`` + = ``TWO_RANGES_FIRST_LIMIT`` (100) (id ``call_0``); + * EXACTLY ONE ``tool``-role read result is in the messages + (content starting with the agent's ``"Document "`` prefix — + ``_READ_RESULT_PREFIX``) AND it carries the ``lines: 1-100 + of …`` extent header (the mock's own keying, asserted): + the SECOND range read — a ``tool_calls`` delta — ``read`` + on the SAME target with ``offset`` = + ``TWO_RANGES_SECOND_OFFSET`` (101) and ``limit`` = + ``TWO_RANGES_SECOND_LIMIT`` (100) (id ``call_1``) — the + flow only works if the second read PROCEEDS (the pre-phase + server would have refused it); + * TWO or more read results are in the messages and the + SECOND carries the ``lines: 101-…`` header (asserted the + same way — the mock's keying stays honest): the forced + answer, byte-stable, in the existing read-echo shape — + ``Both parts read. `` (the + :func:`_same_file_quote` convention — the RANGE reached + the model's context); + * a tool result is in the messages that is not a well-keyed + read result for the step (a refusal — the second range + read was refused, the pre-phase bug — or an unkeyed extent + header): the deterministic broken-premise echo — the + offending result VERBATIM under a ``Premise broke:`` line + — so a suite fails loudly on it (the read-cap flow's + broken-premise convention). + Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint + trigger phrases — the phase-71/72/94/118/131/139 ordering + convention; the trigger needs the ```` section, so + deflected turns never hit it); verified 2026-10-01: 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/ @@ -2465,6 +2524,149 @@ def _same_file_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return ("read1", target) +# --------------------------------------------------------------------------- +# Phase 141 (task 04): the owner's stuck scenario — the model +# range-reads ONE document in TWO different ranges; the +# coverage-tracked server proceeds with the second (the pre-phase +# pair-set dedupe refused it ``ALREADY_IN_CONTEXT`` while only the +# first slice was in the prompt). See the module docstring. +# --------------------------------------------------------------------------- + +#: A user message containing this substring (case-insensitive) — +#: combined with the ```` section in the system prompt — drives +#: the deterministic TWO-RANGES flow (the read range-dedupe fix's +#: story suite, ``tests/e2e/test_read_range_dedupe.py`` — the +#: owner's stuck scenario, TODO.md L3). Checked BEFORE the plain +#: ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — the +#: phase-71/72/94/118/131/139 ordering convention); verified +#: 2026-10-01: no existing E2E question or fixture file contains +#: the phrase, so every other suite is unaffected. +TWO_RANGES_TRIGGER = "read two ranges" + +#: The scripted target in the two-ranges question (case- +#: insensitive — the suite's question capitalizes the trigger's +#: first letter): the verb (``read``) plus the target — a combined +#: ``source/path``, parsed from the RAW user message so the target +#: keeps its case (the ``_SAME_FILE_CALL_RE`` convention; the target +#: is a ``[a-z0-9_./-]`` run, so a `` — `` flavor separator can +#: never bleed into it). +_TWO_RANGES_CALL_RE = re.compile( + r"read two ranges:\s*read\s+(?P[a-z0-9_./-]+)", + re.I, +) + +#: The FIRST range read's requested extent — range A (lines 1–100 +#: of the suite's 200-line fixture document). +TWO_RANGES_FIRST_OFFSET = 1 +TWO_RANGES_FIRST_LIMIT = 100 + +#: The SECOND range read's requested extent — range B (lines 101– +#: …): the SAME file again, a different range. Pre-phase the server +#: refused this call ``ALREADY_IN_CONTEXT`` (the false "full text is +#: in your prompt" claim — the owner's stuck turn); the coverage- +#: tracked server proceeds with it (its lines are NOT covered by +#: the first read's span). +TWO_RANGES_SECOND_OFFSET = 101 +TWO_RANGES_SECOND_LIMIT = 100 + + +def _two_ranges_extent(content: str) -> str | None: + """The ``lines: a-b of N`` extent line of one read result's + content (the ``Document …:`` header already stripped by + :func:`_read_results`, the phase-106 D5 ``date:`` line skipped) + — ``None`` when the result carries no extent line (a plain + read). The flow keys its step on this line (the mock's own + keying, asserted — a refusal or a differently-shaped result is + the broken premise, never a step input). + """ + body = content + if body.startswith("date: "): + body = body.partition("\n")[2] + if body.startswith("lines: "): + return body.partition("\n")[0] + return None + + +def _two_ranges_flow(body: dict[str, Any]) -> tuple[str, ...] | None: + """Classify a phase-141 two-ranges request (see the module + docstring). The question carries its own target (``read two + ranges: read source/path``); the step is then discriminated + statelessly by the tool results in the messages (the house count + pattern — the flow only ever issues ``read`` calls, so counting + read results is reliable): + + * ``("answer", quote)`` — TWO or more ``tool``-role read results + are in the messages and the SECOND carries the ``lines: 101- + …`` extent header (asserted — the second range read landed as + advertised): the forced answer, byte-stable, in the existing + read-echo shape — ``Both parts read. `` with quote the + second result's pure content (header lines skipped, first 80 + chars — the :func:`_same_file_quote` convention; the marker + line lives in range B, so an echo of it proves the slice + reached the model). + * ``("read2", target)`` — EXACTLY ONE read result is in the + messages and it carries the ``lines: 1-100 of …`` extent + header (asserted — the first range read landed as advertised): + the SECOND range read on the SAME target — ``offset`` = + :data:`TWO_RANGES_SECOND_OFFSET`, ``limit`` = + :data:`TWO_RANGES_SECOND_LIMIT` (id ``call_1``). + * ``("read1", target)`` — ``tools`` are offered and no tool + result is in the messages yet: the FIRST range read on the + parsed target — ``offset`` = :data:`TWO_RANGES_FIRST_OFFSET`, + ``limit`` = :data:`TWO_RANGES_FIRST_LIMIT` (id ``call_0``). + * ``("bad", result)`` — a tool result is in the messages that is + not a well-keyed read result for the step (a refusal — the + second range read was refused, the pre-phase bug — or an + unkeyed extent header): the deterministic broken-premise echo + — the offending result verbatim under ``Premise broke:`` (the + read-cap flow's broken-premise convention — a suite fails + loudly on it). + * ``None`` — not the flow: the trigger is absent, the + ```` section is missing (deflected turns never carry + it), the target is unparseable, or ``tools`` are not offered + and no tool result is in the messages yet (e.g. + ``agent_max_rounds=0``). + """ + user = _user(body) + if TWO_RANGES_TRIGGER not in user.lower(): + return None + if "" not in _system(body): + return None + match = _TWO_RANGES_CALL_RE.search(user) + if match is None: + return None + target = match.group("arg") + reads = _read_results(body) + results = _tool_results(body) + if len(reads) >= 2: + extent = _two_ranges_extent(reads[1][1]) + if extent is None or not extent.startswith( + f"lines: {TWO_RANGES_SECOND_OFFSET}-" + ): + return ("bad", reads[1][1]) + return ("answer", _same_file_quote(reads[1][1])) + if len(reads) == 1: + if len(results) > 1: + # A NON-read tool result after the read result — a + # refusal (the pre-phase bug: the second range read was + # refused ``ALREADY_IN_CONTEXT``): the premise broke — + # echo it so a suite fails loudly. + return ("bad", results[-1]) + extent = _two_ranges_extent(reads[0][1]) + if extent is None or not extent.startswith( + f"lines: {TWO_RANGES_FIRST_OFFSET}-{TWO_RANGES_FIRST_LIMIT} of " + ): + return ("bad", reads[0][1]) + return ("read2", target) + if results: + # A tool result that is NOT a read result before any read + # landed — the first read was refused: the premise broke. + return ("bad", results[-1]) + if not body.get("tools"): + return None + return ("read1", target) + + def long_answer() -> str: """~900-word deterministic walkthrough (phase 11): numbered steps plus a unique final line that must survive the stream untruncated.""" @@ -3773,6 +3975,75 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 141 (task 04): the owner's stuck scenario (the read + # range-dedupe fix's story suite, + # tests/e2e/test_read_range_dedupe.py — TODO.md L3): the + # model range-reads ONE document in TWO different ranges — + # the second PROCEEDS on the coverage-tracked server (the + # pre-phase pair-set dedupe refused it ``ALREADY_IN_ + # CONTEXT``), and the answer echoes the second range's + # content. Checked BEFORE the plain TOOLS_TRIGGER flow + # (disjoint trigger phrases — the phase-71/72/94/118/131/ + # 139 ordering convention; the trigger needs the ```` + # section, so deflected turns never hit it). + two_ranges = _two_ranges_flow(body) + if two_ranges is not None: + if two_ranges[0] == "read1": + # The FIRST range read on the parsed target — range + # A (lines 1–100 of the suite's document). + stream = _tool_call_stream( + "read", + { + "path": two_ranges[1], + "offset": TWO_RANGES_FIRST_OFFSET, + "limit": TWO_RANGES_FIRST_LIMIT, + }, + "call_0", + ) + elif two_ranges[0] == "read2": + # The SECOND range read — the SAME file again, a + # different range (range B, lines 101–…). It only + # PROCEEDS on the coverage-tracked server: its lines + # are NOT covered by the first read's span (the pre- + # phase pair-set dedupe refused it — the owner's + # stuck turn). + stream = _tool_call_stream( + "read", + { + "path": two_ranges[1], + "offset": TWO_RANGES_SECOND_OFFSET, + "limit": TWO_RANGES_SECOND_LIMIT, + }, + "call_1", + ) + elif two_ranges[0] == "bad": + # The premise broke (a refusal — the second range + # read was refused — or an unkeyed extent header): + # echo the offending result verbatim so a suite + # fails loudly on it (the read-cap flow's broken- + # premise convention). + stream = _sse_stream( + _apply_max_tokens( + f"Premise broke:\n{two_ranges[1]}", + body.get("max_tokens"), + ), + 0.0, + ) + else: # "answer" — the forced read-echo of range B + # (the marker line lives in range B — the echo of it + # proves the second slice reached the model). + stream = _sse_stream( + _apply_max_tokens( + f"Both parts read. {two_ranges[1]}", + 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_range_dedupe.py b/tests/e2e/test_read_range_dedupe.py new file mode 100644 index 0000000..7c25763 --- /dev/null +++ b/tests/e2e/test_read_range_dedupe.py @@ -0,0 +1,665 @@ +"""Phase 141 task 04 E2E (Playwright, mock-only): the read +range-dedupe fix's dedicated story suite — the owner's stuck +scenario, end to end through the SSE stream. + +TODO.md L3: "From a chat I was having, the LLM is trying to read +different ranges of a document and keeps saying: 'The system is +telling me the document is already in my context, but I haven't +actually seen the full text … The read calls keep returning +"Already in your context"…' This seems like a critical bug, fix +it." Pre-phase, ``AgentHolder``'s pair-set dedupe recorded ANY +successful read — including a RANGE read — as if the FULL document +were in context, so a second read of a DIFFERENT range of the same +document was refused with a claim that was false. Phase 141 tracks +the covered line SPANS (``AgentHolder.read_coverage``) and refuses +only genuinely-covered re-reads (tasks 01–03, unit/integration +pinned) — this suite is the browser-visible proof: the +deterministic mock LLM drives a two-range turn, and the assertions +show both ``read`` tool frames succeeding and the final answer +carrying content from the SECOND range. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — this story's +gate is the deterministic SCRIPTED flow in +``tests/e2e/mock_llm.py`` (the mock is the only E2E lens on the +LLM's context): + +* the two-range turn — ``TWO_RANGES_TRIGGER``: a user message + containing ``read two ranges`` **and** the system prompt carrying + the HIGH prompt's ```` section. Request 1 reads + ``offset=1, limit=100`` (range A) on the question-carried target + (``read two ranges: read source/path``); request 2 (one + ``tool``-role read result in the messages, carrying the + ``lines: 1-100 of …`` extent header — the mock's own keying, + asserted) issues the SECOND read ``offset=101, limit=100`` (range + B) on the SAME target — pre-phase the server refused it + ``ALREADY_IN_CONTEXT`` while only range A was in the prompt; the + flow only works if the second read PROCEEDS. Request 3 (two read + results in the messages, the second carrying the ``lines: 101-…`` + header — asserted the same way) answers ``Both parts read. + `` (the existing read-echo shape) — the marker line lives + in range B, so an echo of it proves the slice reached the model. + A broken premise (a refusal in the messages — the pre-phase bug — + or an unkeyed extent header) lands as the deterministic + ``Premise broke:`` echo, so a suite fails loudly on it. +* the single-range turn — the mock's EXISTING ``READ_LINES_TRIGGER`` + flow (``read the lines``, phase 131 — the grep → + read(offset/limit) dance) reused on THIS suite's KB: the + single-range behavior is unchanged by the coverage rework (the + phase-131 expectation still passes against the coverage-tracked + server). + +The app under test keeps the DEFAULT read cap (128 000 chars) — +deliberately: NO read truncates in this suite. That is the point — +pre-phase, a second range read of a NON-truncated document already +in the pair set was refused ``ALREADY_IN_CONTEXT`` (the phase-139 +suite had to shrink ``BOR_READ_MAX_CHARS`` until the first range +slice truncated just to let a second range read through — that +workaround is retired by the coverage tracking). The still-refused +row (a re-read of FULLY-covered lines) is unit-pinned in tasks +02/03 (``tests/unit/test_agent.py`` — the dedupe matrix); this +suite's job is the HAPPY two-range path, browser-visible. + +KB fixture — ONE deterministic 200-line text document +(``tworanges/two.txt``): fixed-length filler lines (``Line : …`` +— the line numbers are exact) carrying THREE marker lines — +``TWO_RANGES_A_LINE …`` at line 50 (range A), ``TWO_RANGES_B_LINE …`` +at line 101 (the FIRST line of range B — the read-echo quote is the +slice's first 80 chars, so the marker must sit at the range's start +to be echoed), and ``LINE_MARKER …`` at line 30 (the phase-131 +flow's grep target — exactly one line carries the pattern). Each +marker line has two contextual neighbor lines repeating its +distinctive words (the mock's token-overlap embeddings are +diluted by the ~19-line chunks — the neighbors keep the marker +chunk's cosine above the mock-calibrated lexical floor, the +house gate pattern). All line positions are computed from the very +string written to the fixture (never a literal in the +assertions), 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 (the marker chunk wins — ~0.23 / ~0.19 on the mock's +token-overlap distribution, deterministic) clears the floor with +FTS hits (``alpha``/``beta``/``beacon`` and ``trap``/``components`` +in the marker chunks), 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_two_ranges_both_reach_the_model`` — the ``read two + ranges`` question → the SSE ``tool`` frames are EXACTLY the two + range reads of the SAME path (``offset`` 1/``limit`` 100, then + ``offset`` 101/``limit`` 100 — asserted on the frames' ARGS; a + refused second read would re-issue the call and add a THIRD + frame), NO ``tool_result`` frames (nothing truncated — the + default cap), the ``done`` frame is non-deflected with the read + document cited (phase 119 A1), the final answer carries + ``Both parts read.`` + the ``TWO_RANGES_B_LINE`` marker text + (the second slice reached the model's context), and NO refusal + surface anywhere (neither the ``ALREADY_IN_CONTEXT`` line nor + the mock's broken-premise echo is in the answer). +2. ``test_single_range_flow_unchanged`` — the phase-131 ``read the + lines`` question on this KB → the SSE ``tool`` frames are the + grep (the canned pattern) then the range read (``offset`` = the + marker's true line, ``limit`` = 30) — the phase-131 expectation + unchanged against the coverage-tracked server; the final answer + is the grounded echo ``Found it at line `` + the marker text. +""" +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 ALREADY_IN_CONTEXT +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_TWORANGES", "8156")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +SOURCE = "tworanges" # the local directory's basename = the source name +REL = "two.txt" +SP = f"{SOURCE}/{REL}" # the combined read identity + +# The fixture's layout (deterministic — see the module docstring): +# 200 fixed-length filler lines (60 chars each) carrying three +# marker lines, each flanked by two contextual neighbor lines that +# repeat its distinctive words (the mock-calibrated gate — the +# marker chunk's cosine must clear the 0.15 lexical floor). +TOTAL_LINES = 200 +A_MARKER_LINE = 50 # range A (lines 1–100) +B_MARKER_LINE = 101 # range B (lines 101+): the FIRST line of range B +LINE_MARKER_LINE = 30 # the phase-131 flow's grep target (range A) + +#: The range A marker line (lines 1–100 carry it — NOT echoed by the +#: mock; its presence in the fixture is the two-range premise). +A_MARKER_TEXT = ( + "TWO_RANGES_A_LINE the first range documents the alpha beacon payload" +) +A_CTX_ABOVE = ( + "the alpha beacon table starts here: alpha beacon payloads, documented below" +) +A_CTX_BELOW = ( + "the alpha beacon entry ends here — alpha beacon payload documented above" +) + +#: The range B marker line — the FIRST line of range B, so the +#: mock's read-echo quote (the second read result's first 80 chars, +#: header lines skipped) carries it: an echo of it proves the second +#: slice reached the model. +B_MARKER_TEXT = ( + "TWO_RANGES_B_LINE the second range documents the beta beacon payload" +) +B_CTX_ABOVE = ( + "the beta beacon table starts here: beta beacon payloads, documented below" +) +B_CTX_BELOW = ( + "the beta beacon entry ends here — beta beacon payload documented above" +) + +#: The phase-131 flow's marker line: exactly ONE line in the document +#: carries ``READ_LINES_PATTERN`` (imported — the mock can never +#: drift from the fixture). Its true line is computed from the very +#: string written to the fixture (the determinism pin). +LINE_MARKER_TEXT = "LINE_MARKER the trap components are documented here" +LM_CTX_ABOVE = ( + "the trap components table starts here: trap components, documented below" +) +LM_CTX_BELOW = ( + "the trap components entry ends here — trap components documented above" +) + +_FILLER_WORDS = "quiet mirror vault raid pool chain slot cycle guard probe audit " + + +def _filler_line(i: int) -> str: + """One fixed-length (60-char) filler line — the line numbers are + exact and every line is the SAME length, so the range slices' + lengths are deterministic.""" + prefix = f"Line {i:04d}: " + return prefix + (_FILLER_WORDS * 5)[: 60 - len(prefix)] + + +def _doc() -> str: + """The 200-line fixture document (deterministic — module + docstring).""" + lines: list[str] = [] + for i in range(1, TOTAL_LINES + 1): + if i == LINE_MARKER_LINE - 1: + lines.append(LM_CTX_ABOVE) + elif i == LINE_MARKER_LINE: + lines.append(LINE_MARKER_TEXT) + elif i == LINE_MARKER_LINE + 1: + lines.append(LM_CTX_BELOW) + elif i == A_MARKER_LINE - 1: + lines.append(A_CTX_ABOVE) + elif i == A_MARKER_LINE: + lines.append(A_MARKER_TEXT) + elif i == A_MARKER_LINE + 1: + lines.append(A_CTX_BELOW) + elif i == B_MARKER_LINE - 1: + lines.append(B_CTX_ABOVE) + elif i == B_MARKER_LINE: + lines.append(B_MARKER_TEXT) + elif i == B_MARKER_LINE + 1: + lines.append(B_CTX_BELOW) + else: + lines.append(_filler_line(i)) + return "\n".join(lines) + + +DOC = _doc() +DOC_LINES = DOC.splitlines() +DOC_TOTAL = len(DOC) +assert len(DOC_LINES) == TOTAL_LINES, len(DOC_LINES) + +# The determinism pins (computed from the very string written to the +# fixture — the assertions use these, never literals): the markers +# are unique, the B marker is the FIRST line of range B (the +# read-echo quote reaches it), and the range slices are UNDER the +# default read cap (nothing truncates — no ``tool_result`` frames). +assert DOC.count(LINE_MARKER_TEXT) == 1 +assert DOC.count(A_MARKER_TEXT) == 1 +assert DOC.count(B_MARKER_TEXT) == 1 +assert DOC_LINES[B_MARKER_LINE - 1] == B_MARKER_TEXT +assert DOC_LINES[A_MARKER_LINE - 1] == A_MARKER_TEXT +assert DOC_LINES[LINE_MARKER_LINE - 1] == LINE_MARKER_TEXT +#: The default read cap (128 000 — ``app/config.py``) stays in force +#: for this suite; both 100-line slices must stay under it. +assert len("\n".join(DOC_LINES[:100])) < 128_000 +assert len("\n".join(DOC_LINES[100:200])) < 128_000 + +# The expected answer fragments (the mock's read-echo shape — +# ``Both parts read. `` with quote the second read result's +# content, the ``date:`` / ``lines:`` header lines skipped, first 80 +# chars). The B marker line (70 chars) IS the slice's first line, so +# the full marker text sits inside the 80-char quote. +ANSWER_LEAD = "Both parts read." +assert len(B_MARKER_TEXT) < 80, len(B_MARKER_TEXT) + +# The scripted questions. Q_A carries ``TWO_RANGES_TRIGGER`` + the +# target (the question-carried-call convention — the mock parses +# ``read two ranges: read source/path`` from the raw user message); +# Q_B carries the phase-131 ``READ_LINES_TRIGGER`` (the grep → +# read(offset/limit) dance) on this KB. Both carry their marker +# chunks' distinctive words (the mock-calibrated gate stays +# GROUNDED — the ```` section is present, so both flows +# fire). +Q_A = ( + f"Read two ranges: read {SP} — " + "what documents the alpha and beta beacon payloads?" +) +Q_B = f"Read the lines of {SP} — what are the trap components documented here?" + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def tworanges_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 200-line text document. The directory's + basename is the source name (``kind=local``, phase 38).""" + root = tmp_path_factory.mktemp("bor_two_ranges") / SOURCE + root.mkdir() + (root / REL).write_text(DOC, encoding="utf-8") + assert (root / REL).read_text(encoding="utf-8") == DOC + assert not (root / ".git").exists() # the local-kind story: NOT git + return root + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, tworanges_dirs: Path) -> Iterator[str]: + """The real app under test — per-module env (the conftest + pattern, cf. ``test_read_line_ranges.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 DEFAULT read cap stays in force — THE + SUBJECT: no read truncates in this suite, and the second range + read still proceeds (pre-phase, the pair-set dedupe refused a + second range read of a NON-truncated document already in + context — the owner's stuck turn). 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 clears the + # 0.15 floor with FTS hits — the marker chunks win). + 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 — both turns assert 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 + 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, tworanges_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.""" + _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(tworanges_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 + # slice extents every assertion uses come from the very string + # written to the directory. + with SessionLocal() as db: + stored = db.scalar( + select(Document.content).where( + Document.source == SOURCE, Document.path == REL + ) + ) + assert stored == 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_line_ranges / test_read_range_tool_lines +# 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.""" + 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).""" + 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 owner's stuck scenario, browser-visible: two DIFFERENT ranges +# of the same document both reach the model (no refusal) +# -------------------------------------------------------------------------- + + +def test_two_ranges_both_reach_the_model( + 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_A) + _wait_settled(page) + + frames = _frames(page) + # THE SUBJECT on the wire — IN ORDER, asserted on the frames' + # ARGS (not a substring): the SAME path, TWO distinct ranges + # (offset 1/limit 100, then offset 101/limit 100 — the phase-131 + # additive fields ride the SAME frame). EXACTLY two: a refused + # second read (the pre-phase bug) would re-issue the call and + # add a THIRD frame — or land as the mock's broken-premise echo. + assert _tool_frames(frames) == [ + { + "type": "tool", + "name": "read", + "argument": SP, + "offset": 1, + "limit": 100, + }, + { + "type": "tool", + "name": "read", + "argument": SP, + "offset": 101, + "limit": 100, + }, + ], _tool_frames(frames) + # NO ``tool_result`` frame: nothing truncated (the default cap — + # both 100-line slices are far under it; 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). + assert any( + s["path"] == REL and s["source"] == SOURCE for s in done["sources"] + ), done["sources"] + + # The GROUNDED echo — the SECOND range reached the model's + # context and landed in the answer: ``Both parts read.`` + the + # ``TWO_RANGES_B_LINE`` marker text (the marker is range B's + # first line — inside the 80-char read-echo quote). + text = _last_brain(page).locator(".bubble").text_content() or "" + assert ANSWER_LEAD in text, text + assert B_MARKER_TEXT in text, text + # NO refusal surface anywhere in the rendered answer: the + # ``ALREADY_IN_CONTEXT`` line (the pre-phase false claim — its + # full text is the refusal the owner quoted) and the mock's + # broken-premise echo are both absent. + assert ALREADY_IN_CONTEXT not in text, text + assert "Premise broke" not in text, text + + +# -------------------------------------------------------------------------- +# 2. The single-range behavior is unchanged: the phase-131 grep → +# read(offset/limit) dance still lands on the coverage-tracked +# server (the phase-131 expectation, this KB) +# -------------------------------------------------------------------------- + + +def test_single_range_flow_unchanged( + 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_B) + _wait_settled(page) + + frames = _frames(page) + # The phase-131 dance on the wire — IN ORDER, asserted on the + # frames' ARGS: the grep (the mock's 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), limit = READ_LINES_LIMIT (30). + assert _tool_frames(frames) == [ + {"type": "tool", "name": "grep", "argument": READ_LINES_PATTERN}, + { + "type": "tool", + "name": "read", + "argument": SP, + "offset": LINE_MARKER_LINE, + "limit": READ_LINES_LIMIT, + }, + ], _tool_frames(frames) + # The 30-line range slice did NOT truncate (the default cap) — + # no ``tool_result`` frame (the phase-131 pinned order 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 + assert any( + s["path"] == REL and s["source"] == SOURCE for s in done["sources"] + ), done["sources"] + + # The GROUNDED echo — the phase-131 answer shape unchanged: + # ``Found it at line `` (N == the marker's true line) + the + # marker text itself (the single-range read reached the model's + # context exactly as before the coverage rework). + text = _last_brain(page).locator(".bubble").text_content() or "" + assert f"Found it at line {LINE_MARKER_LINE}." in text, text + assert LINE_MARKER_TEXT in text, text diff --git a/tests/integration/test_read_line_ranges_handbook.py b/tests/integration/test_read_line_ranges_handbook.py index 457b0c7..d33dccf 100644 --- a/tests/integration/test_read_line_ranges_handbook.py +++ b/tests/integration/test_read_line_ranges_handbook.py @@ -25,10 +25,11 @@ drives the turn with the owner's ``BOR_READ_MAX_CHARS=24000`` in force: 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 phase-141 COVERAGE dedupe matrix's truncated-then-range row, + end-to-end on the real document — it PROCEEDS; the + ``REREAD_PARTIAL`` teaching — the phase-131 ``REREAD_TRUNCATED`` + line, retired phase 141 — 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 @@ -67,7 +68,7 @@ from app.rag import agent from app.rag.agent import ( AGENT_TOOLS, READ_TRUNCATION_NOTICE, - REREAD_TRUNCATED, + REREAD_PARTIAL, AgentHolder, run_agent, ) @@ -191,7 +192,7 @@ class HandbookAgentLLM: * 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 + reported line, ``limit`` = 40) — the coverage dedupe matrix's truncated-then-range row on the real document; * round 4 → the answer: the read result's content, verbatim. @@ -451,9 +452,13 @@ def test_plain_read_truncates_then_range_read_answers_the_find_traps_question( ] # 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). + # grep-reported line (parsed), limit 40 — and the coverage dedupe + # matrix lets it PROCEED (no ``REREAD_PARTIAL`` teaching anywhere + # in the loop's tool results — the partial-teaching line, re-pinned + # from the retired phase-131 ``REREAD_TRUNCATED``, is derived from + # the REAL document: the truncated plain read covers line 1 + # through the last line inside the cut, the total is the true line + # count). assert llm.calls[1].arguments == {"pattern": PATTERN} grep_line = _first_grep_line(llm.requests[2][0], IDENTITY) read_call = llm.calls[2] @@ -462,7 +467,13 @@ def test_plain_read_truncates_then_range_read_answers_the_find_traps_question( "offset": grep_line, "limit": RANGE_LIMIT, } - reread = REREAD_TRUNCATED.format(shown=READ_CAP, source=SOURCE, path=PDF_NAME) + covered_lines = len(doc.content[:READ_CAP].splitlines()) + reread = REREAD_PARTIAL.format( + covered=f"1–{covered_lines}", + source=SOURCE, + path=PDF_NAME, + total=len(doc.content.splitlines()), + ) tool_results = [ m["content"] for messages, _tools in llm.requests diff --git a/tests/integration/test_read_range_dedupe_two_ranges.py b/tests/integration/test_read_range_dedupe_two_ranges.py new file mode 100644 index 0000000..46c1990 --- /dev/null +++ b/tests/integration/test_read_range_dedupe_two_ranges.py @@ -0,0 +1,536 @@ +"""Integration: phase 141 (task 03) — the owner's stuck two-range +scenario, proven end to end. + +The exact incident from ``TODO.md`` L3: the model was reading +different RANGES of one large document (the D&D "Hail of Thorns" +spell entry) and every second range dead-ended with +``ALREADY_IN_CONTEXT`` — "The system is telling me the document is +already in my context, but I haven't actually seen the full text … +The read calls keep returning 'Already in your context'". Pre-phase, +the pair-set dedupe recorded ANY successful read (a range read +included) as if the FULL document were in context, so every attempt +to read a DIFFERENT range was refused with a false claim. Phase 141 +(the coverage rework, tasks 01/02) makes the dedupe decide from the +lines each read actually put in the model's context: a range read +PROCEEDS unless its lines are fully covered, a plain re-read of a +partially-covered doc gets the pinned +:data:`~app.rag.agent.REREAD_PARTIAL` teaching (the covered span(s) +named) instead of the false full-text claim. + +This suite locks the fix as two scripted reproductions over ONE +generated text document (200 lines, two distinct marker lines far +apart — the content is GENERATED in the test, no binary fixture: the +untracked owner PDF belongs to the separate phase-131 handbook +repro, untouched). The document is inserted directly through the +house KB pattern (``test_agent_tools.py`` — the ``documents`` table +truncated around the test, so the read resolves against exactly this +doc; the agent's read path only reads ``documents``), and a scripted +fake LLM (the ``tests/unit/test_agent.py`` round-shape pattern) +drives ``run_agent``'s loop against the real DB: + +* the **pure dedupe variant** (the doc UNDER the read cap — no + truncation anywhere): round 1 ``read(path, offset=1, limit=40)`` + (range A), round 2 ``read(path, offset=, + limit=30)`` (range B — the PRE-PHASE code refused THIS round with + ``ALREADY_IN_CONTEXT``), round 3 the EXACT re-read of range A + (refused — now with a claim that is true: those lines ARE in the + prompt), round 4 the answer echoing the marker lines the delivered + tool results carry (the read-echo convention — the model can only + echo what reached it; pre-phase, the echo could contain only + marker A). +* the **truncated variant** (the doc OVER the cap — the phase-131 + truncated-then-range row, now via coverage): round 1 a plain + ``read(path)`` (truncated at a cap DERIVED from the generated + content — just past marker A's line + a mid-line cut of the next + line — and it covers only its cut span), round 2 the SAME range-B + read (PROCEEDS — the truncated first read covered only its first + lines), round 3 a PLAIN re-read (the generalized teaching — + :data:`REREAD_PARTIAL` naming the covered span; the retired + phase-131 char-based ``REREAD_TRUNCATED`` line is gone), round 4 + the echo answer (both markers — the truncated first read carried + marker A, the range read carried marker B). + +The marker A line sits INSIDE range A (lines 1–40) — the task's +"near line 50" hint is unsatisfiable with the pinned round shape +(``offset=1, limit=40``) and the pinned echo assertion (the answer +must echo marker A, and the model can only echo what reached it): +line 30 keeps it ten lines clear of range A's end while the markers +stay far apart (120 lines). + +Requires: podman compose up -d db +""" +from __future__ import annotations + +import asyncio +import uuid +from collections.abc import AsyncIterator, Iterator +from copy import deepcopy +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, cast + +import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import Settings +from app.db import SessionLocal +from app.models import Document +from app.rag import agent +from app.rag.agent import ( + READ_TRUNCATION_NOTICE, + AgentHolder, + run_agent, +) +from app.rag.llm import ( + LLMClient, + RetryPiece, + StreamPiece, + ToolCallPiece, + ToolResultPiece, +) +from app.rag.retriever import TRUNCATION_MARKER + +if TYPE_CHECKING: + from app.rag.scaffolding import ScaffoldingFilter + +#: The fixture document's fixed creation date (phase 106, D5) — the +#: ``read`` result's second line formats its UTC date part; a fixed +#: value keeps the pins deterministic. +_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + +#: The document's identity (the house KB pattern: one source, one +#: doc — the owner's spell entry). +SOURCE = "Spellbook" +DOC_PATH = "hail-of-thorns.txt" +IDENTITY = f"{SOURCE}/{DOC_PATH}" + +#: The generated document's shape (see :func:`build_two_range_content`). +TOTAL_LINES = 200 +#: Range A (round 1/3 of the pure-dedupe variant): lines 1–40. +RANGE_A_OFFSET = 1 +RANGE_A_LIMIT = 40 +#: Range B (round 2 of both variants): 30 lines starting 10 lines +#: before marker B (``offset = marker B's line − 10``). +RANGE_B_LIMIT = 30 +#: The marker lines' positions (marker A inside range A — see the +#: module docstring for why the task's "near line 50" hint cannot +#: stand with the pinned round shape + echo assertion). +MARKER_A_LINE = 30 +MARKER_B_LINE = 150 + +#: The two distinct marker lines (each a complete line of the +#: generated document — the echo collects whole lines). +MARKER_A = "MARKER_A_hail-of-thorns-spell-entry-start" +MARKER_B = "MARKER_B_hail-of-thorns-spell-entry-end" + +#: How far past marker A's line the truncated variant's cut runs +#: (see :func:`_truncated_first_cap`): the newline + eight chars of +#: the following line — a MID-LINE cut (the counted-from-truth rule +#: the code uses). +TRUNCATED_FIRST_CUT_TAIL = 8 + +#: The turn's user message (the owner's question in spirit). +QUESTION = "What does the Hail of Thorns spell entry say?" + + +def build_two_range_content() -> str: + """The generated document (no binary fixture): exactly + :data:`TOTAL_LINES` filler lines with the two marker lines in + place — marker A on line :data:`MARKER_A_LINE` (inside range A — + the round-1 slice must carry it for the echo convention to prove + range A reached the model) and marker B on line + :data:`MARKER_B_LINE` (the round-2 slice starts 10 lines before + it).""" + lines = [] + for i in range(1, TOTAL_LINES + 1): + if i == MARKER_A_LINE: + lines.append(MARKER_A) + elif i == MARKER_B_LINE: + lines.append(MARKER_B) + else: + lines.append(f"line {i:03d} filler filler filler") + return "\n".join(lines) + + +def _settings(**kwargs: Any) -> Settings: + """The turn's settings: ``_env_file=None`` keeps an operator's + local ``.env`` out of the pins; ``read_max_chars`` at the default + (the doc fits far under it) unless the test overrides it.""" + kwargs["_env_file"] = None + return Settings(**kwargs) # pyright: ignore[reportCallIssue] + + +def _truncated_first_cap(content: str) -> int: + """The truncated variant's read cap, DERIVED from the generated + *content* (the no-hardcoded-offsets rule): just past marker A's + line — the marker must sit fully INSIDE the cut (the echo of both + markers needs it in the truncated first read; the marker lines + are longer than the filler lines, so a literal char cap would + drift) — plus :data:`TRUNCATED_FIRST_CUT_TAIL` chars of the + following line (a mid-line cut — the counted-from-truth rule).""" + return content.index(MARKER_A) + len(MARKER_A) + 1 + TRUNCATED_FIRST_CUT_TAIL + + +class TwoRangeAgentLLM: + """The scripted two-range loop (the ``test_agent.py`` round-shape + pattern) — deterministic; both variants share one class: + + ``truncated_first=False`` (the pure dedupe variant): + + * round 1 → ``read(path, offset=1, limit=40)`` (range A); + * round 2 → ``read(path, offset=, + limit=30)`` (range B — the pre-phase false refusal); + * round 3 → ``read(path, offset=1, limit=40)`` again (the exact + re-covered range A); + * round 4 → the answer: the marker lines the delivered tool + results carry, in delivery order (the read-echo convention — + the model can only echo what reached it). + + ``truncated_first=True`` (the truncated variant): + + * round 1 → plain ``read(path)`` (truncates at the cap); + * round 2 → the same range-B read (proceeds via coverage); + * round 3 → a plain ``read(path)`` (the ``REREAD_PARTIAL`` + teaching); + * round 4 → the echo answer. + + Every ``chat_stream`` request's messages and tools are recorded + (``requests``); the scripted :class:`ToolCallPiece` s, in call + order, in ``calls``. The echo answer is fed through the round's + :class:`ScaffoldingFilter` exactly like + ``LLMClient.chat_stream`` (the ``test_agent.py`` ScriptedLLM + pattern).""" + + def __init__( + self, identity: str, *, truncated_first: bool, range_b_offset: int + ) -> None: + self.identity = identity + self.truncated_first = truncated_first + self.range_b_offset = range_b_offset + self.requests: list[ + tuple[list[dict[str, Any]], list[dict[str, Any]] | None] + ] = [] + self.calls: list[ToolCallPiece] = [] + + def _next_call(self, index: int) -> ToolCallPiece | None: + """The scripted call for request *index* — ``None`` on the + final (echo) round.""" + range_b = { + "path": self.identity, + "offset": self.range_b_offset, + "limit": RANGE_B_LIMIT, + } + if not self.truncated_first: + if index == 0: + return ToolCallPiece( + id="call_1", + name="read", + arguments={ + "path": self.identity, + "offset": RANGE_A_OFFSET, + "limit": RANGE_A_LIMIT, + }, + ) + if index == 1: + return ToolCallPiece(id="call_2", name="read", arguments=range_b) + if index == 2: + return ToolCallPiece( + id="call_3", + name="read", + arguments={ + "path": self.identity, + "offset": RANGE_A_OFFSET, + "limit": RANGE_A_LIMIT, + }, + ) + return None + if index == 0: + return ToolCallPiece(id="call_1", name="read", arguments={"path": self.identity}) + if index == 1: + return ToolCallPiece(id="call_2", name="read", arguments=range_b) + if index == 2: + return ToolCallPiece(id="call_3", name="read", arguments={"path": self.identity}) + 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) + if call is not None: + self.calls.append(call) + yield call + return + # The echo round: the marker lines the DELIVERED tool results + # carry, in delivery order — the model can only echo what + # reached it (pre-phase, range B's slice never did, so the + # echo could contain only marker A). + marker_lines = [ + line + for m in messages + if m.get("role") == "tool" and isinstance(m.get("content"), str) + for line in m["content"].splitlines() + if MARKER_A in line or MARKER_B in line + ] + content = "\n".join(marker_lines) + 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: TwoRangeAgentLLM, + holder: AgentHolder, + settings: Settings, +) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]: + """Consume one scripted ``run_agent`` turn. SEC-14-04: a + short-lived session per tool call — the same pattern as + production and the phase-70/131 integration suites.""" + 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 two_range_doc(db: Session) -> Iterator[Document]: + """The generated two-range document, indexed as a single row + (the house KB pattern — ``test_agent_tools.py``: the ``documents`` + table truncated around the test, so the read resolves against + exactly this doc). Inserted directly (no importer — the content + is generated in the test, there is no file to import, and the + agent's read path only reads ``documents``).""" + db.execute(text("TRUNCATE chunks, documents, folder_summaries")) + db.commit() + doc = Document( + id=uuid.uuid4(), + source=SOURCE, + path=DOC_PATH, + full_path=f"/tmp/{SOURCE}/{DOC_PATH}", + title="Hail of Thorns", + content=build_two_range_content(), + content_hash="0" * 64, + created_at=_FIXTURE_CREATED_AT, + ) + db.add(doc) + db.commit() + yield doc + db.execute(text("TRUNCATE chunks, documents, folder_summaries")) + db.commit() + db.expire_all() + + +def test_two_ranges_of_one_doc_both_reach_the_model(two_range_doc: Document) -> None: + """The owner's stuck loop, fixed — the pure dedupe case (the doc + UNDER the read cap: no truncation anywhere): range A (lines 1–40) + → range B (the PRE-PHASE pair-set dedupe refused THIS round with + ``ALREADY_IN_CONTEXT`` — "the document is already in my context" + — false: only range A's slice had reached the model) → the EXACT + re-read of range A (refused — the claim in the byte-identical + line is now TRUE: those lines ARE in the prompt) → the answer + echoing BOTH marker lines (range A's slice carried marker A, + range B's slice carried marker B — the read-echo convention: the + model can only echo what reached it; pre-phase, the echo could + contain only marker A).""" + doc = two_range_doc + marker_b_line = doc.content.splitlines().index(MARKER_B) + 1 + llm = TwoRangeAgentLLM( + IDENTITY, truncated_first=False, range_b_offset=marker_b_line - 10 + ) + holder = AgentHolder() + pieces = asyncio.run(_consume(llm, holder, _settings())) + + # Four rounds: range A → range B → re-read range A → echo answer. + assert len(llm.requests) == 4 + assert [call.name for call in llm.calls] == ["read", "read", "read"] + assert llm.calls[0].arguments == { + "path": IDENTITY, + "offset": RANGE_A_OFFSET, + "limit": RANGE_A_LIMIT, + } + assert llm.calls[1].arguments == { + "path": IDENTITY, + "offset": marker_b_line - 10, + "limit": RANGE_B_LIMIT, + } + assert llm.calls[2].arguments == { + "path": IDENTITY, + "offset": RANGE_A_OFFSET, + "limit": RANGE_A_LIMIT, + } + + # Round 1: range A's slice — the true extent on the third line + + # marker A inside. + range_a_result = llm.requests[1][0][3] + assert range_a_result["role"] == "tool" + assert isinstance(range_a_result["content"], str) + total_lines = len(doc.content.splitlines()) + assert range_a_result["content"].startswith( + f"Document {IDENTITY}:\ndate: 2024-06-15\nlines: 1-40 of {total_lines}\n" + ) + assert MARKER_A in range_a_result["content"] + + # Round 2: range B's slice — NOT a refusal (the owner's bug: the + # pre-phase code refused it with ALREADY_IN_CONTEXT), the true + # extent + marker B inside. + range_b_result = llm.requests[2][0][5] + assert range_b_result["role"] == "tool" + assert isinstance(range_b_result["content"], str) + assert range_b_result["content"] != agent.ALREADY_IN_CONTEXT + assert ( + f"lines: {marker_b_line - 10}-{marker_b_line - 10 + RANGE_B_LIMIT - 1} " + f"of {total_lines}\n" in range_b_result["content"] + ) + assert MARKER_B in range_b_result["content"] + + # Round 3: the EXACT re-covered range A — refused (the claim in + # the byte-identical line is now true). + reread_result = llm.requests[3][0][7] + assert reread_result["role"] == "tool" + assert reread_result["content"] == agent.ALREADY_IN_CONTEXT + + # Round 4: the answer echoes BOTH markers (the bug: pre-phase, + # the echo could contain only marker A — range B's slice never + # reached the model). + answer = _answer(pieces) + assert answer == f"{MARKER_A}\n{MARKER_B}" + + # Bookkeeping: the doc committed to the context exactly once; the + # two executed range reads counted (the refusal counted nothing); + # no truncation (the doc is under the cap); the coverage spans + # are the two ranges, in read order. + assert [d.id for d in holder.read_docs] == [doc.id] + assert holder.tool_calls == 2 + assert holder.read_truncations == [] + assert holder.read_coverage == [ + (SOURCE, DOC_PATH, RANGE_A_OFFSET, RANGE_A_LIMIT), + (SOURCE, DOC_PATH, marker_b_line - 10, marker_b_line - 10 + RANGE_B_LIMIT - 1), + ] + + +def test_truncated_plain_read_then_range_b_proceeds_and_plain_reread_teaches( + two_range_doc: Document, +) -> None: + """The truncated variant (the doc OVER the cap — the phase-131 + truncated-then-range row, now via coverage): the plain read + truncates at the cap (it covers only its cut span — the count + from the ACTUAL cut string, the same truth rule the code uses) + and carries the pinned notice; the range-B read PROCEEDS (the + truncated first read covered only its first lines — pre-phase the + row ran through the ``truncated_shown`` map, now through + ``read_coverage``); the plain re-read gets the generalized + ``REREAD_PARTIAL`` teaching (BOTH covered spans named in line + form — the cut span and the already-read range B; the retired + phase-131 char-based ``REREAD_TRUNCATED`` line is gone); the + answer echoes both markers (the truncated first read carried + marker A, the range read carried marker B).""" + doc = two_range_doc + marker_b_line = doc.content.splitlines().index(MARKER_B) + 1 + cap = _truncated_first_cap(doc.content) # derived — see the helper + covered = len(doc.content[:cap].splitlines()) # the cut's truth + llm = TwoRangeAgentLLM( + IDENTITY, truncated_first=True, range_b_offset=marker_b_line - 10 + ) + holder = AgentHolder() + pieces = asyncio.run(_consume(llm, holder, _settings(read_max_chars=cap))) + + # The cut's shape the assertions below lean on: marker A sits + # INSIDE the cut (the echo of both markers needs it in the + # truncated first read), marker B does not (the range-B read is + # what carries it). + assert covered >= MARKER_A_LINE and covered < MARKER_B_LINE + + # Four rounds: plain read → range B → plain re-read → echo. + assert len(llm.requests) == 4 + assert [call.name for call in llm.calls] == ["read", "read", "read"] + # Round 1: the PLAIN read (no range arguments at all). + assert llm.calls[0].arguments == {"path": IDENTITY} + assert llm.calls[1].arguments == { + "path": IDENTITY, + "offset": marker_b_line - 10, + "limit": RANGE_B_LIMIT, + } + assert llm.calls[2].arguments == {"path": IDENTITY} + + # Round 1's result: cut at the cap, the shared marker + the + # pinned notice, marker A inside the cut; the truncation surfaced + # to the API layer as a ToolResultPiece (the SSE + # ``tool_result`` frame's wire value). + plain_result = llm.requests[1][0][3] + assert plain_result["role"] == "tool" + assert isinstance(plain_result["content"], str) + notice = READ_TRUNCATION_NOTICE.format(shown=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}") + assert MARKER_A in plain_result["content"] + truncations = [p for p in pieces if isinstance(p, ToolResultPiece)] + assert [(p.argument, p.chars_shown, p.chars_total) for p in truncations] == [ + (IDENTITY, cap, len(doc.content)) + ] + + # Round 2: the range-B read of the ALREADY-TRUNCATED doc + # PROCEEDS — the slice, not a refusal. + range_b_result = llm.requests[2][0][5] + assert range_b_result["role"] == "tool" + assert isinstance(range_b_result["content"], str) + assert range_b_result["content"] != agent.ALREADY_IN_CONTEXT + total_lines = len(doc.content.splitlines()) + assert ( + f"lines: {marker_b_line - 10}-{marker_b_line - 10 + RANGE_B_LIMIT - 1} " + f"of {total_lines}\n" in range_b_result["content"] + ) + assert MARKER_B in range_b_result["content"] + + # Round 3: the plain re-read of the PARTIALLY-covered doc — the + # generalized teaching naming BOTH covered spans in line form + # (the merged cut span AND the already-read range B — the + # multi-span rendering, in span order), the true total from the + # resolved doc. + reread_result = llm.requests[3][0][7] + assert reread_result["role"] == "tool" + assert reread_result["content"] == agent.REREAD_PARTIAL.format( + covered=f"1–{covered}, {marker_b_line - 10}–{marker_b_line - 10 + RANGE_B_LIMIT - 1}", + source=SOURCE, + path=DOC_PATH, + total=total_lines, + ) + + # Round 4: the answer echoes both markers. + answer = _answer(pieces) + assert answer == f"{MARKER_A}\n{MARKER_B}" + + # Bookkeeping: the doc once; the two successful reads counted (the + # teaching refusal counted nothing); the truncation record is the + # plain read's tuple; the coverage spans — the cut span, then the + # range — in read order. + assert [d.id for d in holder.read_docs] == [doc.id] + assert holder.tool_calls == 2 + assert holder.read_truncations == [(IDENTITY, cap, len(doc.content))] + assert holder.read_coverage == [ + (SOURCE, DOC_PATH, 1, covered), + (SOURCE, DOC_PATH, marker_b_line - 10, marker_b_line - 10 + RANGE_B_LIMIT - 1), + ] diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index 5d0e1ff..9e872ee 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -21,10 +21,18 @@ 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``), +six-row COVERAGE dedupe matrix (phase 141, task 02 — the rework of +the phase-131 pair-set matrix: a range read PROCEEDS unless the +requested lines are FULLY covered by earlier reads — the owner's +stuck-scenario fix, including the phase-131 truncated-then-range row; +a plain re-read of a PARTIALLY-covered doc gets the pinned +:data:`agent.REREAD_PARTIAL` teaching naming the covered span(s); +re-reads of fully-covered lines stay ``ALREADY_IN_CONTEXT`` — the +claim is then true; the raw-arg fast path is RETIRED — every dedupe +row resolves the document first; the task-03 SCENARIO edge: two +adjacent range reads covering half a 200-line doc — the merged span +named in the plain re-read's teaching, the rest of the doc still +readable), :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- @@ -1863,9 +1871,11 @@ def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) """Phase 118 (A6): once the suggested document's full text has been read into context, a SECOND read of the same combined identity is refused with the byte-identical ``ALREADY_IN_CONTEXT`` line — the - dedupe set is ``holder.read_docs`` only, so the refusal fires with - NO DB lookup (the dedupe check precedes the resolve) and the - counters stay untouched.""" + dedupe set is ``holder.read_docs`` only, and the counters stay + untouched. Phase 141 (task 02): the raw-arg fast path is RETIRED + (the merged span's fullness needs the document's true line count, + which only the resolve knows), so the re-read RESOLVES first — + two lookups, not one; the observable refusal is unchanged.""" seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")] lookups: list[tuple[str, str]] = [] @@ -1895,7 +1905,10 @@ def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) asyncio.run(_run(llm, holder, _settings(), seed_docs=seed)) assert holder.read_docs == [seed[0]] # appended exactly once assert holder.tool_calls == 1 # the re-read counts nothing - assert lookups == [("Homelab", "kubernetes.md")] # the re-read deduped pre-lookup + # The re-read now goes through the resolve (the fast path is + # retired — the merged span (1, 1) == the whole 1-line doc makes + # the claim true) — two lookups. + assert lookups == [("Homelab", "kubernetes.md"), ("Homelab", "kubernetes.md")] assert llm.requests[1][0][3]["content"] == ( "Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT" ) @@ -2159,17 +2172,22 @@ def test_bad_range_args_is_pinned() -> 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 " +def test_reread_partial_is_pinned() -> None: + """Phase 141 (task 02): the plain re-read teaching for a + PARTIALLY-covered document is pinned — RETIRING the phase-131 + ``REREAD_TRUNCATED`` line (its char-based ``{shown}`` copy is + replaced by the line-based truth): the covered span(s) in line + form, the RESOLVED identity, the true line count, and the working + workflow (grep reports line numbers → read that part with + ``offset`` + ``limit``).""" + assert agent.REREAD_PARTIAL.format(covered="1–14", source="S", path="big.md", total=20) == ( + "You have lines 1–14 of S/big.md in your context (20 lines " + "total). 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)." + "(1-based first line) and `limit` (number of lines)." ) + # The retired char-based line no longer exists in the module. + assert not hasattr(agent, "REREAD_TRUNCATED") def test_offset_past_end_is_pinned() -> None: @@ -2222,6 +2240,74 @@ def test_read_range_args_bad_values_are_refused( assert line == agent.BAD_RANGE_ARGS.format(offset=offset, limit=limit) +# --- phase 141 (task 02): the coverage helpers (pure, no DB) --- + + +def test_covered_spans_merges_adjacent_overlapping_and_contained() -> None: + """Phase 141 (task 02): ``covered_spans`` selects the RESOLVED + pair's records and MERGES them — ADJACENT spans join (``(1, 10) + + (11, 20)`` → ``(1, 20)``: adjacency is continuous coverage), + overlapping spans collapse to their union, a span CONTAINED in + another is absorbed, and the result is sorted by first line. The + input order is irrelevant; records for other documents are + ignored (the keying is the resolved pair).""" + records = [ + ("S", "a.md", 11, 20), + ("S", "a.md", 1, 10), + ("T", "a.md", 5, 6), # a different source — ignored + ("S", "b.md", 4, 8), # a different path — ignored + ] + assert agent.covered_spans(records, "S", "a.md") == [(1, 20)] + assert agent.covered_spans(records, "S", "b.md") == [(4, 8)] + assert agent.covered_spans(records, "S", "c.md") == [] + # Overlap + containment: (2, 9) + (5, 7) + (1, 3) → (1, 9). + assert agent.covered_spans( + [("S", "x.md", 2, 9), ("S", "x.md", 5, 7), ("S", "x.md", 1, 3)], + "S", + "x.md", + ) == [(1, 9)] + # A single record round-trips unchanged. + assert agent.covered_spans([("S", "y.md", 3, 3)], "S", "y.md") == [(3, 3)] + + +def test_covered_spans_disjoint_spans_stay_separate() -> None: + """Phase 141 (task 02): spans with a GAP between them stay + separate (only adjacency/overlap merge) — the gap is uncovered, + and the span order is by first line regardless of read order.""" + records = [("S", "a.md", 5, 6), ("S", "a.md", 1, 2), ("S", "a.md", 1, 1)] + assert agent.covered_spans(records, "S", "a.md") == [(1, 2), (5, 6)] + + +def test_fully_covered_inclusive_per_line() -> None: + """Phase 141 (task 02): ``fully_covered`` is per line and + INCLUSIVE — the requested span is covered when ONE merged span + starts at or before ``first`` and ends at or after ``last``; a + span crossing a gap is NOT covered even when both endpoints fall + in covered spans; an empty union covers nothing.""" + assert agent.fully_covered([(1, 20)], 1, 20) + assert agent.fully_covered([(1, 20)], 5, 7) + assert agent.fully_covered([(5, 7)], 5, 7) + assert not agent.fully_covered([(1, 20)], 1, 21) + assert not agent.fully_covered([(1, 20)], 21, 22) + assert not agent.fully_covered([], 1, 1) + # The gap edge: 2-5 crosses the uncovered 3-4 between (1, 2) and + # (5, 6) → NOT covered; 1-5 and 5-5 are covered within one span. + assert not agent.fully_covered([(1, 2), (5, 6)], 2, 5) + assert not agent.fully_covered([(1, 2), (5, 6)], 1, 5) + assert agent.fully_covered([(1, 2), (5, 6)], 5, 5) + assert agent.fully_covered([(1, 2), (5, 6)], 1, 2) + + +def test_format_covered_spans_rendering() -> None: + """Phase 141 (task 02): the ``{covered}`` rendering — one span → + the bare ``a–b`` (the en dash), a single line → the bare ``a``, + multiple merged spans → comma-joined pairs in span order.""" + assert agent.format_covered_spans([(1, 14)]) == "1–14" + assert agent.format_covered_spans([(7, 7)]) == "7" + assert agent.format_covered_spans([(1, 2), (5, 6)]) == "1–2, 5–6" + assert agent.format_covered_spans([]) == "" + + def test_bad_range_args_refused_before_any_lookup( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2374,9 +2460,14 @@ def test_matrix_row2_plain_read_truncated_doc_gets_reread_teaching( ) -> 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).""" + ``REREAD_PARTIAL`` teaching naming the covered span — the + truncated first read covers lines 1–14 (the cut counted from the + actual cut string) of the 20-line doc (RE-PINED phase 141: the + char-based ``REREAD_TRUNCATED`` line is retired, the + ``{total}`` comes from the REAL document) — a refusal: no + re-emit, no count. The raw-arg fast path is RETIRED (phase 141 — + the true line count only the resolve knows), so the re-read + resolves the document: TWO lookups, not one.""" cap = 100 doc = _lines_doc("S", "m2.md", 20) # 150 chars > the cap lookups: list[tuple[str, str]] = [] @@ -2404,24 +2495,30 @@ def test_matrix_row2_plain_read_truncated_doc_gets_reread_teaching( + 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" + # Round 2: the plain re-read → the teaching — the covered span + # (1–14: the cut's truth) named, the true line count from the + # resolved document. + assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format( + covered="1–14", source="S", path="m2.md", total=20 ) - # A refusal: the counters stay at the first read's, no re-append, - # no second lookup. + # A refusal: the counters stay at the first read's, no re-append; + # the re-read RESOLVED first (the fast path is retired) — two + # lookups. assert holder.read_docs == [doc] assert holder.tool_calls == 1 - assert lookups == [("S", "m2.md")] + assert lookups == [("S", "m2.md"), ("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).""" + truncated) → ``ALREADY_IN_CONTEXT`` — the byte-identical line, + the row's observable contract unchanged by the phase-141 + rework. The raw-arg fast path is RETIRED (the merged span == the + whole document, but only the resolve knows ``total_lines``), so + the re-read RESOLVES first — TWO lookups, not one (the + fast-path-retirement pin).""" doc = _doc("S", "m3.md", "M3", "FULL-TEXT") # fits under any cap lookups: list[tuple[str, str]] = [] @@ -2439,17 +2536,26 @@ def test_matrix_row3_plain_read_full_doc_stays_already_in_context( 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 + # The re-read now goes through the resolve (the merged span (1, 1) + # == the whole 1-line doc makes the claim true) — two lookups. + assert lookups == [("S", "m3.md"), ("S", "m3.md")] -def test_matrix_row4_range_read_truncated_doc_proceeds( +def test_matrix_row4_range_read_truncated_doc_matrix( 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.""" + """Matrix row 4 (the stuck scenario, re-pinned phase 141 — the + coverage-true form of the phase-131 truncated-then-range row): the + TRUNCATED plain read of this 20-line doc covers lines 1–14 (the + cut counted from the actual cut string) — so a range read of lines + INSIDE that span (5-7) is refused with ``ALREADY_IN_CONTEXT`` + (the claim is now TRUE: those lines ARE in the prompt — the + pre-phase-141 pair-set matrix refused them too, but for the wrong + reason), while a range read of the part the truncated read did NOT + reach (15-16 — the stuck scenario's fix) PROCEEDS with the + ``lines:`` header + exactly the named lines. The doc was appended + ONCE; the proceeding range read counts; 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) @@ -2463,24 +2569,38 @@ def test_matrix_row4_range_read_truncated_doc_proceeds( arguments={"path": "S/m4.md", "offset": 5, "limit": 3}, ) ], + [ + ToolCallPiece( + id="c3", + name="read", + arguments={"path": "S/m4.md", "offset": 15, "limit": 2}, + ) + ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) - # Round 1: the truncated plain read. + # Round 1: the truncated plain read (covers lines 1–14). 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" + # Round 2: the range read of lines INSIDE the covered span (5-7 ⊆ + # 1-14) is refused — the lines ARE in the prompt, the claim is + # true. + assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT + # Round 3: the range read of the part the truncated read did NOT + # reach (15-16) PROCEEDS — the stuck-scenario fix — lines 15-16 + # of 20, exactly. + assert llm.requests[3][0][7]["content"] == ( + "Document S/m4.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n" + "line 15\nline 16" ) assert holder.read_docs == [doc] # appended exactly once - assert holder.tool_calls == 2 # both reads are successful calls + assert holder.tool_calls == 2 # the refusal counted nothing assert holder.read_truncations == [("S/m4.md", cap, len(doc.content))] + assert holder.read_coverage == [("S", "m4.md", 1, 14), ("S", "m4.md", 15, 16)] def test_matrix_row5_range_read_full_doc_stays_already_in_context( @@ -2536,6 +2656,272 @@ def test_matrix_row6_range_read_unread_doc_proceeds( assert holder.read_docs == [doc] and holder.tool_calls == 1 +def test_matrix_row_range_read_of_different_range_proceeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 02) — THE OWNER'S BUG, the matrix row that was + false pre-phase: after a range read of lines 5-7, a range read of + a DIFFERENT range (lines 10-11) of the SAME document PROCEEDS — + the pre-phase pair-set dedupe refused it with ``ALREADY_IN_CONTEXT`` + (the false "full text is in your prompt" claim that dead-ended the + owner's stuck turn). A THIRD read of the already-covered lines 5-7 + is refused with ``ALREADY_IN_CONTEXT`` — the claim is now TRUE + (those exact lines ARE in the prompt). The doc is appended ONCE; + the second range read counts; both spans are recorded in read + order.""" + doc = _lines_doc("S", "m7.md", 12) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/m7.md", "offset": 5, "limit": 3}, + ) + ], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/m7.md", "offset": 10, "limit": 2}, + ) + ], + [ + ToolCallPiece( + id="c3", + name="read", + arguments={"path": "S/m7.md", "offset": 5, "limit": 3}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "Document S/m7.md:\ndate: 2024-06-15\nlines: 5-7 of 12\n" + "line 5\nline 6\nline 7" + ) + # Round 2: the DIFFERENT range PROCEEDS (the owner's stuck + # scenario, fixed — both ranges reach the model). + assert llm.requests[2][0][5]["content"] == ( + "Document S/m7.md:\ndate: 2024-06-15\nlines: 10-11 of 12\n" + "line 10\nline 11" + ) + # Round 3: the re-read of FULLY-covered lines 5-7 is refused — + # the claim in the byte-identical line is now true. + assert llm.requests[3][0][7]["content"] == agent.ALREADY_IN_CONTEXT + assert holder.read_docs == [doc] # appended exactly once + assert holder.tool_calls == 2 # the refusal counted nothing + assert holder.read_coverage == [("S", "m7.md", 5, 7), ("S", "m7.md", 10, 11)] + + +def test_matrix_row_disjoint_spans_gap_plain_teaching_and_merge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 02) — the span-merge edges end to end: two + DISJOINT range reads (1-2 and 5-6 of an 8-line doc) leave lines + 3-4 uncovered. A PLAIN re-read gets ``REREAD_PARTIAL`` naming BOTH + merged spans (``1–2, 5–6`` — the multi-span rendering, the + ``{total}`` from the real doc). A range read SPANNING THE GAP + (2-5) is NOT fully covered → PROCEEDS (lines 3-4 are new), and + after it the merged union is (1, 6) — so a range read of 1-6 is + now FULLY covered → ``ALREADY_IN_CONTEXT`` (the merge made the + claim true).""" + doc = _lines_doc("S", "m8.md", 8) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/m8.md", "offset": 1, "limit": 2}, + ) + ], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/m8.md", "offset": 5, "limit": 2}, + ) + ], + [ToolCallPiece(id="c3", name="read", arguments={"path": "S/m8.md"})], + [ + ToolCallPiece( + id="c4", + name="read", + arguments={"path": "S/m8.md", "offset": 2, "limit": 4}, + ) + ], + [ + ToolCallPiece( + id="c5", + name="read", + arguments={"path": "S/m8.md", "offset": 1, "limit": 6}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + # Rounds 1-2: the two disjoint range reads (both proceed — first + # reads of their spans). + assert llm.requests[1][0][3]["content"] == ( + "Document S/m8.md:\ndate: 2024-06-15\nlines: 1-2 of 8\nline 1\nline 2" + ) + assert llm.requests[2][0][5]["content"] == ( + "Document S/m8.md:\ndate: 2024-06-15\nlines: 5-6 of 8\nline 5\nline 6" + ) + # Round 3: the plain re-read of the PARTIALLY-covered doc — BOTH + # merged spans named (comma-joined, en dash), the true total. + assert llm.requests[3][0][7]["content"] == agent.REREAD_PARTIAL.format( + covered="1–2, 5–6", source="S", path="m8.md", total=8 + ) + # Round 4: the range SPANNING THE GAP (2-5 crosses the uncovered + # 3-4) is NOT fully covered → PROCEEDS. + assert llm.requests[4][0][9]["content"] == ( + "Document S/m8.md:\ndate: 2024-06-15\nlines: 2-5 of 8\n" + "line 2\nline 3\nline 4\nline 5" + ) + # Round 5: after the merge ((1, 2) + (2, 5) + (5, 6) → (1, 6)), + # 1-6 is FULLY covered → the (now true) refusal. + assert llm.requests[5][0][11]["content"] == agent.ALREADY_IN_CONTEXT + assert holder.read_docs == [doc] + assert holder.tool_calls == 3 # the two refusals counted nothing + assert holder.read_coverage == [ + ("S", "m8.md", 1, 2), + ("S", "m8.md", 5, 6), + ("S", "m8.md", 2, 5), + ] + + +def test_matrix_row_adjacent_spans_merge_into_full_coverage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 02) — adjacency is continuous coverage: range + reads of 1-3 and 4-6 of a 6-line doc MERGE into (1, 6) — so a + range read INSIDE the merged span (2-5) is refused with + ``ALREADY_IN_CONTEXT``, and a PLAIN re-read (the merged span == + the whole document) is refused with the same line (the full-doc + row via the merge — no single read covered the whole doc, the + MERGE did).""" + doc = _lines_doc("S", "m9.md", 6) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/m9.md", "offset": 1, "limit": 3}, + ) + ], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/m9.md", "offset": 4, "limit": 3}, + ) + ], + [ + ToolCallPiece( + id="c3", + name="read", + arguments={"path": "S/m9.md", "offset": 2, "limit": 4}, + ) + ], + [ToolCallPiece(id="c4", name="read", arguments={"path": "S/m9.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + # Rounds 1-2: the adjacent range reads (both proceed). + assert llm.requests[1][0][3]["content"].startswith( + "Document S/m9.md:\ndate: 2024-06-15\nlines: 1-3 of 6\n" + ) + assert llm.requests[2][0][5]["content"].startswith( + "Document S/m9.md:\ndate: 2024-06-15\nlines: 4-6 of 6\n" + ) + # Round 3: 2-5 sits inside the merged (1, 6) → refused. + assert llm.requests[3][0][7]["content"] == agent.ALREADY_IN_CONTEXT + # Round 4: the plain re-read — the merged span IS the whole doc + # (1, 6) → the full-doc row, the same line. + assert llm.requests[4][0][9]["content"] == agent.ALREADY_IN_CONTEXT + assert holder.read_docs == [doc] + assert holder.tool_calls == 2 + assert holder.read_coverage == [("S", "m9.md", 1, 3), ("S", "m9.md", 4, 6)] + + +def test_scenario_adjacent_reads_cover_half_doc_teaching_and_rest_proceed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 03) — the adjacent-span edge as a SCENARIO + (the task 02 row pins, end to end over a 200-line doc): two + ADJACENT range reads — 1-60 then 61-100 — together cover lines + 1–100 of the 200-line document (the merge: adjacent spans join + into one). A PLAIN re-read gets ``REREAD_PARTIAL`` naming the + MERGED span (``1–100`` — one span, not two: the merge collapsed + the adjacency) and the true total (200); a read of the REST + (``offset=101`` — no ``limit``, to the end) PROCEEDS with + lines 101-200 (the owner's stuck-scenario fix on a document + whose first half was read in two adjacent hops: the covered + span stops at line 100, the rest is not in context).""" + doc = _lines_doc("S", "half.md", 200) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/half.md", "offset": 1, "limit": 60}, + ) + ], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/half.md", "offset": 61, "limit": 40}, + ) + ], + [ToolCallPiece(id="c3", name="read", arguments={"path": "S/half.md"})], + [ + ToolCallPiece( + id="c4", + name="read", + arguments={"path": "S/half.md", "offset": 101}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + # Rounds 1-2: the adjacent range reads (both proceed — first + # reads of their spans). + assert llm.requests[1][0][3]["content"].startswith( + "Document S/half.md:\ndate: 2024-06-15\nlines: 1-60 of 200\n" + ) + assert llm.requests[2][0][5]["content"].startswith( + "Document S/half.md:\ndate: 2024-06-15\nlines: 61-100 of 200\n" + ) + # Round 3: the plain re-read — the MERGED span named as one + # (adjacent spans join: 1–100), the true total from the resolved + # doc. + assert llm.requests[3][0][7]["content"] == agent.REREAD_PARTIAL.format( + covered="1–100", source="S", path="half.md", total=200 + ) + # Round 4: the REST of the document (offset=101, no limit → to + # the end) PROCEEDS — the covered span stops at line 100. + assert llm.requests[4][0][9]["content"] == ( + "Document S/half.md:\ndate: 2024-06-15\nlines: 101-200 of 200\n" + + "\n".join(f"line {i}" for i in range(101, 201)) + ) + assert holder.read_docs == [doc] + assert holder.tool_calls == 3 # the teaching refusal counted nothing + assert holder.read_coverage == [ + ("S", "half.md", 1, 60), + ("S", "half.md", 61, 100), + ("S", "half.md", 101, 200), + ] + + # --- the slice math + the result shape --- @@ -2716,12 +3102,15 @@ def test_range_read_slice_over_cap_truncates_with_slice_length_total( 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).""" + """Phase 131 (task 02), re-pinned phase 141: 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_PARTIAL`` naming the covered span — LINE 1 ONLY (the + cut of the truncated range read covers its one visible line; the + single-line rendering is the bare ``a``) with the true line + count (RE-PINED: the char-based ``REREAD_TRUNCATED`` line is + retired); a subsequent range read still PROCEEDS (the truncated- + then-range row — the requested 5-6 is not covered).""" cap = 20 lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] content = "\n".join(lines) @@ -2757,10 +3146,11 @@ def test_range_read_truncated_then_plain_reread_teaching_then_range_proceeds( + "\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 2: the plain re-read → the teaching — the covered span + # (line 1 only: the cut string's splitlines() is one line) named, + # the true line count from the resolved document. + assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format( + covered="1", source="S", path="t.md", total=10 ) # 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 @@ -2779,13 +3169,18 @@ def test_range_read_truncated_then_plain_reread_teaching_then_range_proceeds( assert holder.tool_calls == 2 # the refusal counted nothing -def test_plain_read_after_range_read_is_already_in_context( +def test_plain_read_after_range_read_gets_partial_teaching( 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).""" + """Phase 131 (task 02), re-pinned phase 141 (task 02): a range + read covers ONLY its own span (lines 1-2 of a 3-line doc), so a + later PLAIN read of the document is the PARTIAL row — + ``REREAD_PARTIAL`` naming the covered span (``1–2``) and the true + total (RE-PINED: the pre-phase-141 ``ALREADY_IN_CONTEXT`` line + claimed the FULL text was in the prompt — false, only 2 of 3 + lines were; the false full-text claim is gone in BOTH refusal + directions). The doc is still appended to ``read_docs`` ONCE + (the read-once bookkeeping is unchanged).""" doc = _doc("S", "p.md", "P", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() @@ -2804,7 +3199,441 @@ def test_plain_read_after_range_read_is_already_in_context( 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.REREAD_PARTIAL.format( + covered="1–2", source="S", path="p.md", total=3 + ) + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +# --- phase 141 (task 01): coverage tracking (which lines a read put +# --- in the model's context — the state task 02's dedupe matrix +# --- decides on) --- + + +def test_coverage_plain_read_under_cap_records_full_span( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a plain read at or under the cap covers the + WHOLE document — one span ``(source, path, 1, total_lines)`` keyed + by the RESOLVED pair (the same pair ``read_docs`` keys on). The + existing signals stay byte-identical (unchanged meaning).""" + doc = _lines_doc("S", "cv1.md", 4) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv1.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "Document S/cv1.md:\ndate: 2024-06-15\nline 1\nline 2\nline 3\nline 4" + ) + assert holder.read_coverage == [("S", "cv1.md", 1, 4)] + assert holder.read_docs == [doc] and holder.tool_calls == 1 + assert holder.read_truncations == [] + + +def test_coverage_truncated_plain_read_records_cut_span( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a truncated plain read covers from line 1 + to the last line visible in the ACTUAL cut string — ``len( + content[:cap].splitlines())`` counted from the cut (the char cap + is never re-derived into lines arithmetically). The 150-char + 20-line fixture under a 100-char cap cuts mid-line 14 (its + visible prefix ``li`` is the last line the model saw), so the + span is ``(1, 14)``.""" + cap = 100 + doc = _lines_doc("S", "cv2.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/cv2.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + assert len(doc.content[:cap].splitlines()) == 14 # the cut's truth + assert holder.read_coverage == [("S", "cv2.md", 1, 14)] + assert holder.read_truncations == [("S/cv2.md", cap, len(doc.content))] + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +def test_coverage_truncated_plain_read_cut_at_line_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): the boundary sub-case of the same + counted-from-truth rule — a cap that lands EXACTLY on a line + boundary (the cut ends with the line's newline): the last visible + line is complete and the span ends there. ``aa\nbb\ncc\ndd`` + under a 6-char cap cuts ``aa\nbb\n`` → the span is ``(1, 2)``.""" + cap = 6 + doc = _doc("S", "cv3.md", "Cv3", "aa\nbb\ncc\ndd") # 11 chars > cap + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv3.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + assert llm.requests[1][0][3]["content"] == ( + "Document S/cv3.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_coverage == [("S", "cv3.md", 1, 2)] + assert holder.read_truncations == [("S/cv3.md", cap, 11)] + + +def test_coverage_range_read_under_cap_records_true_extent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a range read at or under the cap covers + exactly the TRUE extent the ``lines:`` header reports — + ``(first, end)``. An overshooting ``limit`` clamps ``end`` to the + last line (the span never goes past it); ``offset`` without + ``limit`` reads to the end; ``limit`` without ``offset`` counts + from line 1.""" + doc = _doc("S", "cv4.md", "Cv4", "a\nb\nc\nd") + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + # (a) offset=2 + an overshooting limit → the clamped extent 2-4. + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/cv4.md", "offset": 2, "limit": 10}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "Document S/cv4.md:\ndate: 2024-06-15\nlines: 2-4 of 4\nb\nc\nd" + ) + assert holder.read_coverage == [("S", "cv4.md", 2, 4)] + # (b) offset without limit → reads to the end (2-3 of 3). + doc3 = _doc("S", "cv4b.md", "Cv4b", "a\nb\nc") + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc3) + holder2 = AgentHolder() + llm2 = ScriptedLLM( + [ + ToolCallPiece( + id="c1", name="read", arguments={"path": "S/cv4b.md", "offset": 2} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm2, holder2, _settings())) + assert holder2.read_coverage == [("S", "cv4b.md", 2, 3)] + # (c) limit without offset → a line count from the start (1-2 of 4 + # on the 4-line doc). + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder3 = AgentHolder() + llm3 = ScriptedLLM( + [ + ToolCallPiece( + id="c1", name="read", arguments={"path": "S/cv4.md", "limit": 2} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm3, holder3, _settings())) + assert llm3.requests[1][0][3]["content"] == ( + "Document S/cv4.md:\ndate: 2024-06-15\nlines: 1-2 of 4\na\nb" + ) + assert holder3.read_coverage == [("S", "cv4.md", 1, 2)] + + +def test_coverage_truncated_range_read_records_cut_extent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a truncated RANGE read covers from + ``first`` to the last line visible in the cut SLICE — ``first - 1 + + len(slice[:cap].splitlines())``, clamped to ``end`` (the same + counted-from-truth rule as the plain read — ``slice_text[:cap]`` + is the actual cut string the model saw). The 203-char lines-5-10 + slice under a 20-char cap cuts mid-line-5 (``L05`` + 17 ``x``), + so the span is ``(5, 5)``.""" + cap = 20 + lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 × 33 chars + doc = _doc("S", "cv5.md", "Cv5", "\n".join(lines)) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/cv5.md", "offset": 5, "limit": 6}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + slice_text = "\n".join(lines[4:10]) # 203 chars > the cap + assert len(slice_text[:cap].splitlines()) == 1 # only line 5 is visible + assert llm.requests[1][0][3]["content"] == ( + "Document S/cv5.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)) + ) + assert holder.read_coverage == [("S", "cv5.md", 5, 5)] + assert holder.read_truncations == [("S/cv5.md", cap, len(slice_text))] + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +def test_coverage_truncated_range_read_cut_at_line_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): the boundary sub-case for the SLICE cut — + a cap that lands exactly on a line boundary (the cut ends with the + line's newline): the span ends at that complete line. The 8-char + lines-1-3 slice under a 5-char cap cuts ``aa\nbb\n`` → the span is + ``(1, 2)`` (``end`` = 3, the clamp not reached).""" + cap = 5 + doc = _doc("S", "cv6.md", "Cv6", "aa\nbb\ncc") # 8 chars > cap + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/cv6.md", "offset": 1, "limit": 3}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + assert llm.requests[1][0][3]["content"] == ( + "Document S/cv6.md:\ndate: 2024-06-15\nlines: 1-3 of 3\n" + + doc.content[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content)) + ) + assert holder.read_coverage == [("S", "cv6.md", 1, 2)] + assert holder.read_truncations == [("S/cv6.md", cap, 8)] + + +def test_coverage_spans_accumulate_in_read_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): each SUCCESSFUL read appends its own span, + in read order — a truncated plain read (span 1) followed by the + range read that proceeds (phase-131 row 4) leaves BOTH spans, + keyed by the same resolved pair.""" + cap = 100 + doc = _lines_doc("S", "cv7.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/cv7.md"})], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/cv7.md", "offset": 15, "limit": 2}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + # Round 2 PROCEEDED (the truncated-then-range row) — lines 15-16 + # (15 chars — under the cap, no second truncation). + assert llm.requests[2][0][5]["content"] == ( + "Document S/cv7.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n" + "line 15\nline 16" + ) + assert holder.read_coverage == [("S", "cv7.md", 1, 14), ("S", "cv7.md", 15, 16)] + assert holder.tool_calls == 2 + assert len(holder.read_truncations) == 1 + + +def test_coverage_spans_keyed_by_the_resolved_pair( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): the span is keyed by the RESOLVED + ``(source, path)`` pair — the document's own identity (the + ``read_docs`` keying; by the ``_resolve_path`` first-slash + contract the raw argument's split and the resolved pair agree — + the same pair ``read_truncations`` keying recovers from the raw + argument). A nested path (slashes in the path part) keeps the + pair intact: the canonical ``source/path`` argument read records + the document's ``(source, path)``, not a mangled split.""" + doc = _doc("S", "dir/nested/file.md", "File", "one\ntwo") + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/dir/nested/file.md", "offset": 1, "limit": 1}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "Document S/dir/nested/file.md:\ndate: 2024-06-15\n" + "lines: 1-1 of 2\none" + ) + assert holder.read_coverage == [("S", "dir/nested/file.md", 1, 1)] + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +# --- phase 141 (task 01): refusals record NOTHING --- + + +def test_coverage_bad_range_args_records_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a bad range argument (the pre-dedupe + validation refusal) records NO span — a refused read counts in + nothing, including the coverage.""" + doc = _lines_doc("S", "cvr1.md", 5) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", + name="read", + arguments={"path": "S/cvr1.md", "offset": 2.5}, + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == agent.BAD_RANGE_ARGS.format( + offset=2.5, limit=None + ) + assert holder.read_coverage == [] + assert holder.read_docs == [] and holder.tool_calls == 0 + + +def test_coverage_unknown_doc_records_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): a read that resolves to NO document (the + no-document refusal) records NO span.""" + monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) + monkeypatch.setattr(agent, "all_documents", lambda db: []) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments={"path": "S/ghost.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "No document at 'S/ghost.md' — check the ls output." + ) + assert holder.read_coverage == [] + assert holder.read_docs == [] and holder.tool_calls == 0 + + +def test_coverage_offset_past_end_records_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): an ``offset`` past the end (the + post-resolve teaching refusal) records NO span — the document is + NOT added to the context, so a corrected retry still proceeds""" + doc = _lines_doc("S", "cvr3.md", 10) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="c1", name="read", arguments={"path": "S/cvr3.md", "offset": 11} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format( + source="S", path="cvr3.md", total_lines=10, offset=11 + ) + assert holder.read_coverage == [] + assert holder.read_docs == [] and holder.tool_calls == 0 + + +def test_coverage_plain_reread_of_full_doc_records_nothing_new( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): the plain re-read of a read-in-full doc + (``ALREADY_IN_CONTEXT`` — the raw-arg fast path) appends NOTHING — + the list still holds the first read's span and nothing else.""" + doc = _doc("S", "cvr4.md", "Cv4", "FULL-TEXT") # one line, under any cap + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr4.md"})], + [ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr4.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"].startswith("Document S/cvr4.md:") assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT + assert holder.read_coverage == [("S", "cvr4.md", 1, 1)] # still ONE span + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +def test_coverage_plain_reread_of_truncated_doc_records_nothing_new( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01; the refusal re-pinned task 02): the plain + re-read of a TRUNCATED doc (``REREAD_PARTIAL`` — the partial- + coverage row) appends NOTHING — the list still holds the first + read's cut span only.""" + cap = 100 + doc = _lines_doc("S", "cvr5.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/cvr5.md"})], + [ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr5.md"})], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) + assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format( + covered="1–14", source="S", path="cvr5.md", total=20 + ) + assert holder.read_coverage == [("S", "cvr5.md", 1, 14)] # still ONE span + assert holder.read_docs == [doc] and holder.tool_calls == 1 + + +def test_coverage_range_reread_of_full_doc_records_nothing_new( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 141 (task 01): the range read of a read-in-full doc + (``ALREADY_IN_CONTEXT`` — the post-resolve row) appends NOTHING — + the list still holds the first read's full span only.""" + doc = _lines_doc("S", "cvr6.md", 5) + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr6.md"})], + [ + ToolCallPiece( + id="c2", + name="read", + arguments={"path": "S/cvr6.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_coverage == [("S", "cvr6.md", 1, 5)] # still ONE span assert holder.read_docs == [doc] and holder.tool_calls == 1 diff --git a/tests/unit/test_prompt_lock.py b/tests/unit/test_prompt_lock.py index 45f3937..6baac77 100644 --- a/tests/unit/test_prompt_lock.py +++ b/tests/unit/test_prompt_lock.py @@ -72,12 +72,17 @@ def test_high_and_low_bases_byte_locked() -> None: #: phase 118 (task 04, A6, owner directive 2026-09-15): the ``read`` #: clause was rewritten for the summary-seed mode (the ```` #: section holds SUMMARIES — ``read`` adds the full text; only an -#: already-read document is refused). Only that clause moved — the -#: prefix (the ``ls``-clause opening) and the suffix (the -#: discipline-rules ending) survived byte-identical, so they are the -#: same anchors as pre-phase-118. -TOOLS_SECTION_SHA256 = "87ee80faf0170da6ab1de518177313def6460785613fa074312a2a5f9f071750" -TOOLS_SECTION_LEN = 2465 +#: already-read document is refused). RE-CUT again for phase 141 +#: (task 02): the re-read sentence was re-cut to the coverage-true +#: form — do not re-read a document read IN FULL (answer from it); +#: a PARTIAL document (a range read, or a truncated read) is not +#: complete in the context until read all — read the other parts with +#: ``offset``/``limit``. Only that sentence moved — the prefix (the +#: ``ls``-clause opening) and the suffix (the discipline-rules +#: ending) survived byte-identical, so they are the same anchors as +#: pre-phase-141. +TOOLS_SECTION_SHA256 = "3671680bbf4e04efe1186690c18d24869e4974ceb29485cedc3e34a9286bf3b2" +TOOLS_SECTION_LEN = 2656 TOOLS_SECTION_PREFIX = ( "\n" "You may extend your context with three tools. `ls` lists the " diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py index db0ebc3..f55252e 100644 --- a/tests/unit/test_prompts.py +++ b/tests/unit/test_prompts.py @@ -339,23 +339,39 @@ def test_tools_section_phase118_summary_seed_read_clause() -> None: """Phase 118 (task 04, A6): the ``read`` clause is rewritten for the summary-seed mode — the ```` section holds SUMMARIES (a suggested document's full text is not in the prompt - until ``read`` adds it); do not re-read an already-read document - (answer from the text already in the prompt); if the user asks to + until ``read`` adds it); do not re-read a document already read + IN FULL (answer from the text already in the prompt); a PARTIAL + document (a range read, or a truncated read — re-pinned phase + 141, task 02: the re-read sentence is the coverage-true form — + read the other parts with ``offset``/``limit``, the document is + not complete in the context until read all); if the user asks to open or read a suggested document, ``read`` it. The phase-72 "do not call ``read`` for a ```` document" copy is retired. The ``ls`` clause (phase 94 drill-down contract), the ``grep`` clause, and the discipline rules stay byte-identical — the prompt lock's prefix/suffix anchors survive (see ``test_prompt_lock``).""" - # The new summary-seed copy (pinned byte-for-byte). + # The summary-seed copy (pinned byte-for-byte — the phase-141 + # coverage-true re-read sentence re-pins the pre-phase-141 line; + # the section's other bytes are unchanged). assert ( "The section holds SUMMARIES — the full text of a " "suggested document is not in your prompt until you `read` it. " - "Do not re-read a document you have already read — its full " - "text is already in your prompt; answer directly from it. If " - "the user asks you to open or read a suggested document, " - "`read` it — that is the point of the section." + "Do not re-read a document you have already read in full — " + "its full text is in your prompt, answer from it. If you have " + "read only part of a document (a range read, or a truncated " + "read), read the other parts with `offset` and `limit` — the " + "document is not complete in your context until you have read " + "it all. If the user asks you to open or read a suggested " + "document, `read` it — that is the point of the section." ) in TOOLS_SECTION + # The retired pre-phase-141 re-read copy is gone (it claimed the + # FULL text for a document that may be only partially in the + # context — the owner's stuck-turn bug). + assert ( + "Do not re-read a document you have already read — its full " + "text is already in your prompt; answer directly from it" + ) not in TOOLS_SECTION # The read identity handoff now also names the summary # blocks (the combined identity is shown there), keeping the # "including the source name" contract.