All green. Final report: **Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)** - **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers. - **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings. - **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes). - **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed. - **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched. - **Next pending phase:** `161_reasoning_bubble`.
540 lines
22 KiB
Python
540 lines
22 KiB
Python
"""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"].startswith(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")
|
||
# Phase 160 (task 01): the budget-telemetry trailer rides the
|
||
# truncated result — the marker + notice end the RAW part.
|
||
raw = plain_result["content"].removesuffix(agent._budget_note(9))
|
||
assert raw.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"].startswith(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),
|
||
]
|