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
+51 -7
View File
@@ -63,7 +63,23 @@ of the answer's ``delta`` frames: ``argument`` is the single string the
model passed — ``read``'s ``path`` (the combined ``source/path``),
``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string
value — a model error the backend refuses — and an omitted argument
both yield null). ``done.sources``, ``query_log.sources`` and the
both yield null).
Tool-result frames (phase 95, ``TODO.md`` L5 — A15 extension, owner
permission 2026-09-10; the event-type list grows from six to SEVEN,
``tool_result`` among them; PLAN.md is being redone by the owner): a
``read`` whose document is longer than ``BOR_READ_MAX_CHARS`` streams,
AFTER the matching ``tool`` frame (the line is already on screen — the
marker lands a beat later, the phase-37/48 tool-line timing is
untouched) and BEFORE the next model round, exactly one optional
``tool_result`` event — ``{"type": "tool_result", "name": "read",
"argument": …, "truncated": true, "chars_shown": N, "chars_total": M}``
— the additive truncation notice the UI turns into the
"(truncated — showing N of M chars)" marker on the Reading line. A
non-truncated read streams NO such frame (one frame = one noteworthy
event), deflected turns never stream one (the agent never runs, A8),
and every pre-existing frame is byte-identical — clients that do not
know the type ignore it. ``done.sources``, ``query_log.sources`` and the
per-turn log line all report the same combined source list (retrieval
docs + the agent's read docs, deduped by ``(source, path)``, order
preserved — a grep adds no source; it is a locator, locked A5), and the
@@ -165,6 +181,7 @@ from app.rag.llm import (
RetryPiece, # phase 67: one LLM request restart (an SSE retry frame)
StreamPiece, # type of the answer pieces streamed by the agent loop
ToolCallPiece, # phase 37: one model-requested tool call
ToolResultPiece, # phase 95: one truncated tool result (SSE frame = task 02)
chat_stream_retried, # phase 67: the retry-before-first-piece primitive
)
from app.rag.overview import load_kb_overview
@@ -179,6 +196,7 @@ from app.schemas import (
ChatRetryEvent,
ChatThinkingEvent,
ChatToolEvent,
ChatToolResultEvent,
SourceRef,
)
@@ -435,7 +453,9 @@ async def chat(
# ``agent_max_rounds=0`` ``run_agent`` is a single
# ``tools=None`` request anyway (the kill switch).
holder = AgentHolder()
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]
answer_stream: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
]
deflected_filter: ScaffoldingFilter | None = None
if plan.deflected:
# Phase 67: the deflected stream goes through the retry
@@ -474,13 +494,18 @@ async def chat(
scaffold_stripped = 0 # phase 71: sum across the turn's requests
async def _pump(
pieces: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece],
pieces: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
],
) -> AsyncIterator[str]:
"""One request's piece loop (phase 71 extraction): the
thinking/tool/retry/delta handling shared by the turn's
first pass and — deflected path only — the one bounded
recovery. Behavior-preserving for the first pass (pinned
by the existing integration suite)."""
thinking/tool/retry/tool_result/delta handling shared by
the turn's first pass and — deflected path only — the one
bounded recovery. Behavior-preserving for the first pass
(pinned by the existing integration suite). Phase 95:
the ``ToolResultPiece`` branch emits the additive
``tool_result`` SSE frame (the seventh, optional event
type — the A15 extension)."""
nonlocal thinking_chars, content_chars, retries_used
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece):
@@ -513,6 +538,25 @@ async def chat(
).model_dump()
)
continue
if isinstance(piece, ToolResultPiece):
# Phase 95 (A15 extension, task 02): one optional
# ``tool_result`` frame per truncated ``read`` —
# emitted HERE, right where the agent loop yielded
# the piece: AFTER the matching ``tool`` frame and
# BEFORE the next model round. Additive: a
# non-truncated read yields no piece at all (no
# frame), and the other six event types are
# byte-identical.
yield sse_event(
ChatToolResultEvent(
name=piece.name,
argument=piece.argument,
truncated=piece.truncated,
chars_shown=piece.chars_shown,
chars_total=piece.chars_total,
).model_dump()
)
continue
if piece.kind == "thinking":
thinking_chars += len(piece.text)
if settings.stream_thinking:
+28
View File
@@ -152,6 +152,23 @@ class Settings(BaseSettings):
#: request with ``tools=None`` (the pre-phase-37 path — the kill
#: switch). Negative values are rejected at startup (validator).
agent_max_rounds: int = 10
#: Cap in characters on the agent ``read`` tool's result (phase 95,
#: ``BOR_READ_MAX_CHARS``): a document LONGER than this is cut at the
#: cap and the shared ``[…truncated…]`` marker plus the grep-pointer
#: notice (``app.rag.agent``) are appended; a document at or under the
#: cap is read whole, byte-identical to the pre-phase-95 result. Spec
#: rationale (pinned): 128 000 chars ≈ **32 000 tokens** at the
#: ~4-chars/token house estimate (``app.rag.llm``'s embed batching
#: notes ~3 chars/token for code-dense text, 4 for prose) — a quarter
#: of the 128k-token **minimum** context the owner's LLMs all have, so
#: a truncated read still leaves ~96k tokens for the system prompt, the
#: top-2 ``<documents>``, the tool rounds, and the 32 768-token answer
#: cap (``max_output_tokens``). Char-based (no tokenizer in the repo —
#: the ``BOR_SUMMARY_MAX_CHARS`` precedent) and env-tunable in both
#: directions. This is the ONLY truncated read path (owner permission
#: 2026-09-10, ``TODO.md`` L5): A7's never-truncated contract is for
#: the retrieval ``<documents>`` path, which stays whole.
read_max_chars: int = 128_000
# --- Hybrid retrieval (A7, revised 2026-08-21) ---
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
@@ -274,6 +291,17 @@ class Settings(BaseSettings):
raise ValueError("agent_max_rounds must be >= 0 (0 = no tools)")
return v
@field_validator("read_max_chars")
@classmethod
def _read_max_chars_non_negative(cls, v: int) -> int:
"""A negative cap is a typo — it would slice from the END of the
content (negative indexing) instead of failing. Fail loud at
startup (the ``agent_max_rounds`` pattern). ``0`` is legal (every
non-empty read truncates to the marker + notice)."""
if v < 0:
raise ValueError("read_max_chars must be >= 0 (chars)")
return v
@field_validator("llm_retries")
@classmethod
def _llm_retries_non_negative(cls, v: int) -> int:
+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 "
+40
View File
@@ -153,6 +153,35 @@ class ChatToolEvent(BaseModel):
argument: str | None = None # the single string argument passed, or null
class ChatToolResultEvent(BaseModel):
"""SSE frame for one executed tool call whose result was truncated
(phase 95, ``TODO.md`` L5).
A15 extension (owner permission 2026-09-10 — recorded in the phase 95
overview ``00_phase.md``; PLAN.md is being redone by the owner): the
SSE event-type list grows from six to SEVEN — ``thinking``,
``tool``, ``retry``, ``delta``, ``done``, ``error`` and this
optional ``tool_result``. The frame is strictly ADDITIVE: existing
frames and clients are untouched (a client that does not know the
type simply ignores it), and it is emitted ONLY for a truncated
``read`` — one frame per truncated read, carrying the counts the UI
renders as "(truncated — showing N of M chars)". It always follows
the matching :class:`ChatToolEvent` frame for the same call (the
line is already on screen; the marker lands a beat later — the
phase-37/48 tool-line lifecycle is untouched). ``argument`` is the
combined ``source/path`` the model passed (identical to the matching
``tool`` frame's argument, so the client can match the two); a
non-truncated read streams NO frame of this type.
"""
type: str = "tool_result"
name: str # the tool that was executed (always "read" today)
argument: str | None = None # the model's argument (combined source/path)
truncated: bool = True # always True on a sent frame (the emission trigger)
chars_shown: int = Field(ge=0) # the cap kept (settings.read_max_chars)
chars_total: int = Field(ge=0) # the document's true length
class ChatDoneEvent(BaseModel):
"""Final SSE event of a chat turn: metadata for the finished answer."""
@@ -456,10 +485,21 @@ class ToolCall(BaseModel):
``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the
combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2×
headroom for a grep pattern).
Phase 95 (task 02): the truncation marker the UI renders next to the
Reading line rides the SAME record — ``truncated`` (default False:
the pre-phase-95 shape) + the two non-negative counts. Small
additive fields with defaults, no migration (``ChatMessage.tools``
is JSON) — a saved chat written before phase 95 (no fields) validates
UNCHANGED (the phase-50 backward-compat rule) and renders without
the marker.
"""
name: str = Field(max_length=100)
argument: str | None = Field(default=None, max_length=2000)
truncated: bool = False
chars_shown: int | None = Field(default=None, ge=0)
chars_total: int | None = Field(default=None, ge=0)
#: One suggestion chip (phase 83, A1): a short deterministic string —