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