phase: 95_read_truncation_cap
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:
+115
-10
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user