phase: 95_read_truncation_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m38s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All gates are green. Final verification is complete — no fixes were needed; the phase is fully implemented and passing.

**Phase 95 final verification report**

- Verified all 3 tasks already implemented (task files in `complete/`): read cap + notice + `ToolResultPiece` (task 01), `tool_result` SSE + live/saved/shared marker (task 02), E2E suite (task 03).
- Code checks passed: `BOR_READ_MAX_CHARS` (default 128 000, validator), pinned `READ_TRUNCATION_NOTICE` + `TRUNCATION_MARKER`, byte-identical at/under-cap read, holder `read_truncations` (no `tool_calls` impact), piece order (after `tool`, before next round), `ChatToolResultEvent`, `ToolCall` back-compat fields, `app.js` live/restore handlers, `shared.js`, `.truncated-note` CSS, `.env.example` entry, mock-LLM echo script.
- **Tests:** `uv run pytest` → 1966 passed; `uv run pytest --cov=app --cov-report=term-missing` → all green, TOTAL **99%** (>90% gate).
- **E2E:** `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → **3 passed** (frame order + live marker + LLM notice via echo; save→shared fidelity; short-read control).
- **Regression (isolated):** `test_agent_document_tools` 4 ✓, `test_chat_history` 5 ✓, `test_share_chat` 4 ✓, `test_big_read_progress` 4 ✓, `test_stop_generation` 3 ✓.
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.

**Completion criteria:** ① over-cap read → first-cap-chars + marker + pinned notice — ✓ (unit-pinned: at-cap/cap+1/notice tests); ② user marker live/saved/shared — ✓ (E2E + frontend tests); ③ at/under cap byte-identical, no frame — ✓ (unit + control E2E); ④ top-2 `<documents>` retrieval untouched — ✓ (`app/rag/retriever.py` unmodified vs HEAD); ⑤ suite green, >90% coverage, ruff+pyright clean — ✓; ⑥ no completed-phase behavior change — ✓ (all gates green; commit left to harness per pass rules).

- No defects found; no changes made this pass. Next pending phase: none in `todo/` (96 is the next free number).
This commit is contained in:
2026-09-11 03:42:51 -04:00
parent d4943b4822
commit bcaef800c5
36 changed files with 2836 additions and 43 deletions
+115 -10
View File
@@ -86,8 +86,21 @@ task 04):
:data:`NOT_A_FOLDER` teaching (below) —
``read`` takes the combined ``source/path`` string, splits it at the
FIRST ``'/'`` (source names are directory basenames — they can never
contain ``'/'``), and returns the document's **full** content
(A7-revised contract: never truncated) — and ``grep`` greps the
contain ``'/'``), and returns the document's content — WHOLE at or
under ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default
128 000 chars ≈ 32k tokens), and CAPPED above it (phase 95, owner
permission 2026-09-10, ``TODO.md`` L5): the first ``read_max_chars``
chars plus the shared :data:`TRUNCATION_MARKER` and the pinned
:data:`READ_TRUNCATION_NOTICE` (the rest is NOT in the model's
context; ``grep`` — which searches the whole document — is the
follow-up), with the truncation recorded on the holder so the loop
yields a :class:`app.rag.llm.ToolResultPiece` (task 02 → the SSE
``tool_result`` frame + UI marker). **A7 scope clarification:** the
never-truncated contract is for the retrieval ``<documents>`` path
(the top-2 seed documents stay whole — "this should never happen");
the ``read`` TOOL path is the only capped read, per the owner's
explicit request — the two paths are distinct (retrieval seeds vs.
agent-requested additions). And ``grep`` greps the
indexed documents (or the one document a combined ``source/path``
names) for a case-insensitive fixed substring and returns up to 20
``source/path:line: text`` match lines (owner-locked A5, phase 68),
@@ -212,7 +225,7 @@ import logging
import re
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
from typing import Any, cast
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -227,8 +240,10 @@ from app.rag.llm import (
RetryPiece,
StreamPiece,
ToolCallPiece,
ToolResultPiece,
chat_stream_retried,
)
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.scaffolding import ScaffoldingFilter
from app.rag.source_removal import resolve_source_name
@@ -289,8 +304,14 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"open or read it — its full text is already in your "
"prompt; answer directly from it. Use it only to add a "
"document NOT already in <documents> to your context, "
"by its combined `source/path` string. Call one tool at "
"a time — wait for this result before your next call."
"by its combined `source/path` string. Very large "
"documents are truncated: you receive the first part "
"plus a TRUNCATED notice naming how many more characters "
"exist — the notice is authoritative, the document did "
"NOT end where it stopped. Follow it and use `grep` "
"(pattern) to locate the rest — it searches the whole "
"document. Call one tool at a time — wait for this "
"result before your next call."
),
"parameters": {
"type": "object",
@@ -381,6 +402,25 @@ UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
#: The read-truncation notice (phase 95, task 01) — appended, after the
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
#: is LONGER than ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``,
#: default 128 000 chars ≈ 32k tokens). Two format fields: ``{total}``
#: (the document's true character count) and ``{shown}`` (the cap — how
#: many characters actually reached the model). The copy is pinned: it
#: tells the model the read was truncated, that the rest is NOT in its
#: context (the document did not end where it stopped), and names
#: ``grep`` — the phase-70 harness-aligned locator that searches the
#: WHOLE document — as the tool to find what it was looking for. A read
#: at or under the cap appends nothing (byte-identical to the
#: pre-phase-95 result).
READ_TRUNCATION_NOTICE = (
"TRUNCATED — this document is {total} characters; only the first "
"{shown} are in your context. The rest is NOT shown. Use grep "
"(pattern) to locate what you need — grep searches the whole "
"document."
)
#: The no-source ``ls`` refusal with the teaching parenthetical
#: appended (phase 72): used when a stripped scope has no ``/`` and
#: matches no registered source (the incident's ``ls(path='.')``). The
@@ -995,11 +1035,22 @@ class AgentHolder:
forced final + any recovery, phase 71) — drives the per-turn log
line's ``scaffold_stripped=N`` field on grounded turns (the
deflected path computes its own total in ``app.api.chat``).
``read_truncations`` (phase 95): one ``(argument, chars_shown,
chars_total)`` tuple per TRUNCATED ``read`` — the combined
``source/path`` the model passed, the cap kept
(``settings.read_max_chars``), and the document's true length — in
execution order. Recorded on truncation only; a read at or under the
cap appends nothing. A truncated read is still a **successful**
call: ``tool_calls`` increments as today and ``read_docs`` appends
as today — this list only carries the truncation signal the agent
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
surfaces them to the UI).
"""
read_docs: list[Document] = field(default_factory=list)
tool_calls: int = 0
scaffold_stripped: int = 0
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
def _execute_tool(
@@ -1007,6 +1058,7 @@ def _execute_tool(
call: ToolCallPiece,
seed_docs: Sequence[Document],
holder: AgentHolder,
settings: Settings,
) -> str:
"""Execute one tool call server-side (DB only).
@@ -1016,7 +1068,13 @@ def _execute_tool(
it is a locator, locked A5); rejected calls return their refusal
line and count in nothing. A grep that ran but found nothing is
still a successful (counted) call — its no-match line is a result,
not a refusal. Document targets are combined ``source/path``
not a refusal. A ``read`` longer than ``settings.read_max_chars``
(phase 95) is cut at the cap and carries the shared
:data:`TRUNCATION_MARKER` + the pinned :data:`READ_TRUNCATION_NOTICE`
(the rest of the document is NOT in the model's context), and a
``(argument, cap, total)`` tuple is appended to
``holder.read_truncations`` (the signal the agent loop turns into a
:class:`app.rag.llm.ToolResultPiece`). Document targets are combined ``source/path``
strings, resolved by :func:`_resolve_path` (the canonical identity,
phase 70).
"""
@@ -1098,6 +1156,30 @@ def _execute_tool(
return _no_document_refusal(db, arg)
holder.read_docs.append(doc)
holder.tool_calls += 1
cap = settings.read_max_chars
if len(doc.content) > cap:
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
# read cap — cut at the cap and tell the model the truth: the
# shared marker + the pinned notice name how much more exists
# and point at ``grep`` (which searches the WHOLE document).
# A truncated read is still a successful call (both counters
# above already bumped); the holder tuple is the truncation
# signal only, so the loop can yield a ToolResultPiece.
# ``raw_path`` (a str here — ``arg`` is non-empty only when it
# was) is the exact argument the matching SSE ``tool`` frame
# carries, so the UI can match the two (task 02); the cast
# keeps pyright honest about the holder tuple's first element.
holder.read_truncations.append(
(cast("str", raw_path), cap, len(doc.content))
)
return (
f"Document {doc.source}/{doc.path}:\n"
f"{doc.content[:cap]}\n"
f"{TRUNCATION_MARKER}\n"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
)
# At or under the cap: byte-identical to the pre-phase-95 result
# (no marker, no notice, no holder entry, no ToolResultPiece).
return f"Document {doc.source}/{doc.path}:\n{doc.content}"
if call.name == "grep":
raw_pattern = call.arguments.get("pattern")
@@ -1170,14 +1252,19 @@ async def run_agent(
settings: Settings,
holder: AgentHolder,
history: Sequence[dict[str, Any]] = (),
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]:
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
"""Run the grounded-turn tool loop, yielding every stream piece.
Every piece (``thinking`` / ``content`` / tool calls /
:class:`RetryPiece`) is yielded as it arrives; the API layer (task 04)
turns tool-call pieces into SSE ``tool`` events and retry pieces into
SSE ``retry`` events. After the loop finishes, *holder* carries the
read documents and the executed tool-call count (re-lists included).
SSE ``retry`` events. A truncated ``read`` (phase 95 — the document is
longer than ``settings.read_max_chars``) additionally yields one
:class:`ToolResultPiece` per truncated call, AFTER the round's
``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.
History (phase 74, TODO L4): *history* is the client's prior turns
already mapped to model messages by
@@ -1350,7 +1437,12 @@ async def run_agent(
"to stream"
)
call = calls[0] # a stream can carry several calls; run the first
result = _execute_tool(db, call, seed_docs, holder)
# Phase 95: snapshot the truncation list BEFORE the execution so
# only the entries THIS call added are surfaced (one read per
# round, so at most one new entry — the loop still iterates the
# tail, so a future multi-call round stays correct).
trunc_before = len(holder.read_truncations)
result = _execute_tool(db, call, seed_docs, holder, settings)
rounds += 1 # every call the model emits consumes a round
logger.info(
"agent tool=%s args=%s round=%d/%d",
@@ -1376,6 +1468,19 @@ async def run_agent(
}
)
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
# Phase 95: surface this round's truncation(s) to the API layer.
# The piece lands AFTER the round's ``tool`` frame (the matching
# ``ToolCallPiece`` was already streamed, above) and BEFORE the
# next model round (the loop continues below) — task 02 turns it
# into an SSE ``tool_result`` frame + the UI marker.
for argument, shown, total in holder.read_truncations[trunc_before:]:
yield ToolResultPiece(
name=call.name,
argument=argument,
truncated=True,
chars_shown=shown,
chars_total=total,
)
if rounds >= max_rounds:
logger.warning(
"agent round cap reached (rounds=%d) — forcing a final "
+30
View File
@@ -89,6 +89,36 @@ class ToolCallPiece:
arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResultPiece:
"""One executed tool call whose result was truncated (phase 95).
A15 extension (owner permission 2026-09-10, ``TODO.md`` L5 — recorded
in the phase 95 overview ``00_phase.md``; PLAN.md is being redone by
the owner): the ``read`` tool caps its result at
``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default 128 000
chars ≈ 32k tokens). When a document is longer than the cap, the agent
loop appends the shared ``[…truncated…]`` marker + the grep-pointer
notice to the result the model sees AND yields one of these pieces so
the API layer can surface the truncation to the user (an SSE
``tool_result`` frame, task 02). It is the ONLY piece the agent loop
yields that does not come from the model stream — it is derived from
the executed call. ``argument`` is the combined ``source/path`` the
model passed (the same value the matching ``tool`` frame carries),
``chars_shown`` is the cap (``settings.read_max_chars``) and
``chars_total`` is the document's true length — so the UI can render
"(truncated — showing N of M chars)". ``truncated`` is always ``True``
on a yielded piece (a non-truncated read yields nothing). Frozen like
its siblings: an immutable wire value.
"""
name: str # the tool that was executed (always "read" today)
argument: str | None # the model's argument (the combined source/path)
truncated: bool # always True on a yielded piece
chars_shown: int # the cap actually kept (settings.read_max_chars)
chars_total: int # the document's true length
@dataclass(frozen=True)
class RetryPiece:
"""One LLM request retry that is about to start (phase 67, locked A2).
+4 -1
View File
@@ -173,7 +173,10 @@ TOOLS_SECTION: str = (
"the <documents> section, even when the user asks you to open or "
"read it — its full text is already in your prompt; answer "
"directly from it. For `read`, a bare document path (without the "
"source name) will not resolve. `grep` locates an exact string "
"source name) will not resolve. Very large documents are capped: a "
"cut read returns the first part plus a TRUNCATED notice — the "
"document did not end where it stopped; use `grep` (pattern) to "
"find the rest, it searches the whole document. `grep` locates an exact string "
"(case-insensitive) in the indexed documents and returns up to 20 "
"matching `source/path:line: text` lines — a locator, not a "
"context-adder: read the winner with `read`. A grep pattern is a "