phase: 141_read_range_dedupe_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m37s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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`.
This commit is contained in:
2026-10-01 01:27:46 -04:00
parent de49a03c4d
commit a8c0402ad2
33 changed files with 3520 additions and 203 deletions
@@ -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`.
@@ -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
@@ -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`.
@@ -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
@@ -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`
@@ -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
@@ -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`
@@ -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
@@ -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).
@@ -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
@@ -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. <first 80 chars of the second read result, read-echo shape>"`; 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).
@@ -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
+22 -1
View File
@@ -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 |
+270 -111
View File
@@ -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
``<documents>`` 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()``).
+14 -6
View File
@@ -197,10 +197,15 @@ SUGGEST_INTRO = (
#: phase-118 summary-seed contract (the ``<documents>`` 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 ``<document path="...">`` 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 ``<tools>``
#: 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 ``<document path="...">`` 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 "
"<documents> 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 "
+276 -5
View File
@@ -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 ``<tools>`` 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 ``<tools>`` 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. <first 80 chars of the second read
result's content, header lines skipped>`` (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 ``<tools>`` 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 ``<tools>`` 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<arg>[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. <quote>`` 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
``<tools>`` 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 "<tools>" 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 ``<tools>``
# 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 ``<documents>`` block with the image-
+665
View File
@@ -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 ``<tools>`` 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.
<first 80 chars of the second read result's content, header lines
skipped>`` (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 <i>: …``
— 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
``<tools>`` section is present) and the read document clears the
usefulness bar into ``done.sources``.
Test → observable mapping (Playwright Mapping Rule; the story is
the owner's TODO item — one Playwright file per story, A16):
1. ``test_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 <N>`` + 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. <quote>`` 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 ``<tools>`` 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>`` (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
@@ -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
@@ -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=<marker B's line − 10>,
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=<marker B's line − 10>,
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),
]
+886 -57
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -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 ``<documents>``
#: 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 = (
"<tools>\n"
"You may extend your context with three tools. `ls` lists the "
+23 -7
View File
@@ -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 ``<documents>`` 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 ``<documents>`` 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 <documents> 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 <documents> summary
# blocks (the combined identity is shown there), keeping the
# "including the source name" contract.