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:
+51
-7
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user