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
@@ -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),
]