phase: 192_verbatim_inline_midturn
Build and Push Containers / build-and-push-app (push) Successful in 1m56s
Build and Push Containers / build-and-push-db (push) Successful in 14s

Phase 192 — final verification pass complete.

- **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite.
- **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**.
- `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%)
- `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated)
- `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts)
- `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors**
- **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run).
- **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream).
- **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened.
- **Next pending phase:** `193_sync_remaining_count`.
This commit is contained in:
2026-10-19 12:19:51 -04:00
parent 4f20c256d7
commit fb17d15fbe
49 changed files with 17313 additions and 102 deletions
+59 -9
View File
@@ -251,6 +251,24 @@ keeps its exact pre-phase bytes). The per-turn log line records
ACTUALLY SENT (after dedupe and the ``show_text_max_blocks`` cut), 0 on
every turn without them including deflected ones (the PLAN §9 additive
revision).
Mid-turn verbatim frame (phase 192, task 01, A1; ``TODO.md`` L5): the
same block ALSO reaches the client the moment the ``show_text`` call
succeeds, as the additive ``verbatim_block`` event —
``{"type": "verbatim_block", "source", "path", "title", "start_line",
"end_line", "total_lines", "text", "truncated", "locator"}`` (the exact
:class:`~app.schemas.VerbatimBlockRef` field set, so the one panel client
accepts it as-is). It lands AFTER the round's ``tool`` / ``tool_result`` frames and
BEFORE the next model round — while the answer is still streaming, so the
panel appears where the model introduced the bytes instead of popping in
after it finished talking. Success-only: every refusal (missing ``path``,
no document, an image row, the block cap, the already-shown dedupe) emits
nothing, and a ``BOR_ANSWER_TEXT=0`` turn offers no tool, so its frame
sequence is byte-identical to pre-phase. Everything downstream of the
turn keeps the phase-182 contract EXACTLY: the ``done`` frame's
``answer_text`` list, the saved record key, the restore path and the
shared page are untouched, and ``verbatim_blocks=N`` still counts the
``done``-frame blocks (never the mid-turn frames).
"""
from __future__ import annotations
@@ -275,7 +293,9 @@ from app.models import Document, QueryLog
from app.rag.agent import (
CORRECTION_INSTRUCTION, # phase 71: the harness-owned recovery line
AgentHolder,
AgentPiece, # the ONE union run_agent yields (phase 192: + VerbatimBlockPiece)
MalformedReplyError, # phase 71: the recovery policy's terminal signal
VerbatimBlockPiece, # phase 192: one shown verbatim block (mid-turn frame)
run_agent,
suggested_folder_lines, # phase 119 (D3): the suggested-folder context lines
)
@@ -285,7 +305,6 @@ from app.rag.llm import (
LLMClient,
LLMError,
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
@@ -323,6 +342,7 @@ from app.schemas import (
ChatThinkingEvent,
ChatToolEvent,
ChatToolResultEvent,
ChatVerbatimBlockEvent,
SourceRef,
VerbatimBlockRef,
)
@@ -974,9 +994,11 @@ 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 | ToolResultPiece
]
# Phase 192: ``AgentPiece`` is the ONE union ``run_agent``
# yields (the phase-95 truncation piece plus the new
# ``VerbatimBlockPiece``); the deflected path's narrower
# ``chat_stream_retried`` union flows into it unchanged.
answer_stream: AsyncIterator[AgentPiece]
deflected_filter: ScaffoldingFilter | None = None
if plan.deflected:
# Phase 67: the deflected stream goes through the retry
@@ -1038,9 +1060,7 @@ async def chat(
tools_offered = not plan.deflected
async def _pump(
pieces: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
],
pieces: AsyncIterator[AgentPiece],
tools_offered: bool = True,
) -> AsyncIterator[str]:
"""One request's piece loop (phase 71 extraction): the
@@ -1058,10 +1078,14 @@ async def chat(
frame, no visible markup) and counted in a local so one
debug line can name it. The grounded first pass keeps its
current behavior (``tools_offered=True``; ``run_agent``
suppresses the cap-path calls internally)."""
suppresses the cap-path calls internally). Phase 192
(task 01): the ``VerbatimBlockPiece`` branch emits the
additive ``verbatim_block`` frame — the verbatim block a
successful ``show_text`` just displayed, so its panel
lands mid-stream instead of at ``done``."""
nonlocal thinking_chars, content_chars, retries_used
dropped_calls = 0 # phase 160: tools=None requests only
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
async for piece in pieces: # app.rag.agent.AgentPiece
if isinstance(piece, ToolCallPiece):
if not tools_offered:
# Phase 160 (task 02): a tools=None request
@@ -1170,6 +1194,32 @@ async def chat(
).model_dump()
)
continue
if isinstance(piece, VerbatimBlockPiece):
# Phase 192 (task 01, A1): the mid-turn verbatim
# frame — the block the ``show_text`` call just
# displayed, sent at the moment it exists (the
# panel lands while the answer is still
# streaming instead of at ``done``). Additive:
# a refused call yields no piece (no frame), a
# ``answer_text=False`` turn offers no tool (no
# frame) — the frame sequence is byte-identical
# to pre-phase in both cases, and the ``done``
# frame's ``answer_text`` list and its
# ``verbatim_blocks=N`` count are untouched.
yield sse_event(
ChatVerbatimBlockEvent(
source=piece.block.source,
path=piece.block.path,
title=piece.block.title,
start_line=piece.block.start_line,
end_line=piece.block.end_line,
total_lines=piece.block.total_lines,
text=piece.block.text,
truncated=piece.block.truncated,
locator=piece.block.locator,
).model_dump()
)
continue
if piece.kind == "thinking":
thinking_chars += len(piece.text)
if settings.stream_thinking:
+88 -3
View File
@@ -233,7 +233,17 @@ task 04):
:data:`SHOW_TEXT_NOT_A_TEXT_DOC` refusal, the exact mirror of
:data:`NOT_AN_IMAGE_DOC` — never a silent conversion), and the inline
``` ```bor ``` locator-fence form of the same contract (phase 182 D2)
is resolved at the API layer (task 05), NOT here.
is resolved at the API layer (task 05), NOT here. **Phase 192 (task
01, A1): the block reaches the user at the CALL moment, not at the
end of the answer** — the round loop snapshots
``len(holder.shown_texts)`` before the execution and yields one
:class:`VerbatimBlockPiece` for whatever the call appended, which the
API layer turns into the additive ``verbatim_block`` SSE frame (the
panel lands while the model is still talking). Success-only, like the
holder entry: a refusal appends nothing and yields nothing. The
``done`` frame's ``answer_text`` list and the per-turn
``verbatim_blocks=N`` count (the done-frame blocks) are UNCHANGED —
that log field never counts the mid-turn frames.
4. Every call the model emits is appended back to the message history as
the assistant tool-call message + the tool result (refusals included),
consumes one round, and the model is called again. Every tool result
@@ -344,6 +354,55 @@ from app.rag.verbatim import VerbatimBlock, block_for
logger = logging.getLogger("app.agent")
@dataclass(frozen=True)
class VerbatimBlockPiece:
"""One verbatim block a ``show_text`` call JUST displayed (phase 192).
The mid-turn sibling of :class:`app.rag.llm.ToolResultPiece` (phase
95 — the other piece the agent loop yields that does NOT come from
the model stream: it is derived from the executed call). Phase 182
handed the block to the client only on the ``done`` frame's
``answer_text`` list, so every panel popped in AFTER the model had
finished talking about the bytes; phase 192 (task 01, A1) sends the
very same block the moment it exists, as the additive
``verbatim_block`` SSE frame — AFTER the round's ``tool`` /
``tool_result`` frames and BEFORE the next model round.
SUCCESS-ONLY by construction: the single gate is the
``holder.shown_texts.append`` in the ``show_text`` branch — every
refusal (missing args, the ``answer_text=False`` posture, no
document, an image row, the block cap, the already-shown dedupe)
returns before it, so a refused call yields NO piece. :attr:`block`
is the exact :class:`app.rag.verbatim.VerbatimBlock` the tool put on
the holder (the server's bytes and the server's extent — LOCKED
A1/A3), never a re-derivation. Frozen like its siblings: an
immutable wire value. The ``done`` frame's ``answer_text``
list and its ``verbatim_blocks=N`` count are UNCHANGED by this
piece (task 05's resolution still runs over the finished answer);
the mid-turn frames are not counted there.
"""
#: The block the call displayed — the same object that rode
#: ``holder.shown_texts`` (the phase-182 task-03 lineage: the
#: server resolved it, the tool appended it on success only).
block: VerbatimBlock
#: The ONE union of every piece :func:`run_agent` yields — the model
#: stream's pieces (:class:`app.rag.llm.StreamPiece` /
#: :class:`~app.rag.llm.ToolCallPiece` / :class:`~app.rag.llm.RetryPiece`)
#: plus the pieces DERIVED from an executed call: the phase-95
#: :class:`~app.rag.llm.ToolResultPiece` (a truncated ``read``) and the
#: phase-192 :class:`VerbatimBlockPiece` (a successful ``show_text``, the
#: mid-turn ``verbatim_block`` frame). Named so the wire consumers — the
#: ``app.api.chat`` pump and the test harnesses — spell the SAME union and
#: a future piece type is added in exactly one place.
type AgentPiece = (
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece | VerbatimBlockPiece
)
#: The three agent tools (phase 70: the harness-aligned surface —
#: ``ls`` / ``read`` / ``grep``, the pi.dev tool shapes the model was
#: trained on, replacing the phase-37 list/read and phase-68 search
@@ -2413,7 +2472,7 @@ async def run_agent(
settings: Settings,
holder: AgentHolder,
history: Sequence[dict[str, Any]] = (),
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
) -> AsyncIterator[AgentPiece]:
"""Run the grounded-turn tool loop, yielding every stream piece.
Every piece (``thinking`` / ``content`` / tool calls /
@@ -2423,7 +2482,18 @@ async def run_agent(
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,
into an SSE ``tool_result`` frame, task 02). A successful
``show_text`` (phase 182) additionally yields exactly ONE
:class:`VerbatimBlockPiece` per block it appended to
``holder.shown_texts``, with the SAME emission-order contract —
AFTER the round's ``tool`` / ``tool_result`` frames and BEFORE the
next model round — so the API layer can put the verbatim panel on
screen the moment the call succeeds instead of at ``done`` (phase
192, task 01, A1). Emission rides the ``shown_texts`` append, which
is the branch's SINGLE success gate: every refusal (missing ``path``,
the ``answer_text=False`` posture, no document, an ``is_image`` row,
the ``show_text_max_blocks`` cap, the already-shown dedupe) returns
before it and emits nothing. After the loop finishes,
*holder* carries the read documents, the executed tool-call count
(re-lists included), the ``read_truncations`` signal list, and the
``read_coverage`` spans (phase 141, task 01 — the lines each
@@ -2640,6 +2710,12 @@ async def run_agent(
# 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)
# Phase 192 (task 01, A1): the same snapshot-then-yield shape for
# the verbatim blocks — only what THIS call appended becomes a
# ``VerbatimBlockPiece`` (a round adds at most one, the
# already-shown dedupe being a refusal, but the tail loop keeps a
# future multi-add correct).
shown_before = len(holder.shown_texts)
with db_factory() as tool_db:
result = _execute_tool(tool_db, call, seed_docs, holder, settings)
rounds += 1 # every call the model emits consumes a round
@@ -2694,6 +2770,15 @@ async def run_agent(
chars_shown=shown,
chars_total=total,
)
# Phase 192 (task 01, A1): surface this round's verbatim block(s)
# to the API layer — the block the ``show_text`` call JUST
# displayed, sent at the moment it exists instead of at ``done``.
# Same emission order as the truncation piece above (AFTER the
# round's ``tool`` / ``tool_result`` frames, BEFORE the next model
# round); task 02 turns it into the additive ``verbatim_block``
# SSE frame. A refused call appended nothing, so nothing is sent.
for block in holder.shown_texts[shown_before:]:
yield VerbatimBlockPiece(block=block)
if rounds >= max_rounds:
logger.warning(
"agent round cap reached (rounds=%d) — forcing a final "
+40
View File
@@ -347,6 +347,46 @@ class ChatToolResultEvent(BaseModel):
chars_total: int = Field(ge=0) # the document's true length
class ChatVerbatimBlockEvent(BaseModel):
"""SSE frame for one verbatim block a ``show_text`` call JUST displayed
(phase 192, task 01, A1).
The MID-TURN twin of one :class:`VerbatimBlockRef` item of the ``done``
frame's ``answer_text`` list — the same field set with the same caps,
sent the moment the block exists instead of at the end of the answer
(``TODO.md`` L5: "the verbatim tag appear[s] at the end and only when
the llm is done answering which makes the chat read strangely"). The
frame is strictly ADDITIVE (the phase-95 ``tool_result`` precedent):
it lands AFTER the round's ``tool`` / ``tool_result`` frames and
BEFORE the next model round, clients that do not know the type ignore
it, a REFUSED ``show_text`` streams NO frame (the holder append is the
single success gate), and a ``BOR_ANSWER_TEXT=0`` turn offers no tool
at all — its frame sequence is byte-identical to pre-phase. The
``done`` frame's ``answer_text`` list, the saved record and the
restore path keep the exact phase-182 contract (the client's
idempotence keeps the same block from painting twice).
``isVerbatimBlock`` (``frontend/assets/verbatim.js``) accepts the
event as-is: the key set is the done-frame item's key set, so the one
panel client hydrates a mid-turn frame with no new shape. The field
caps are :class:`VerbatimBlockRef`'s verbatim — a unit pin fails the
day the two drift.
"""
type: Literal["verbatim_block"] = "verbatim_block"
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
start_line: int = Field(ge=0)
end_line: int = Field(ge=0)
total_lines: int = Field(ge=0)
#: The verbatim bytes themselves — SERVER-supplied, never model output
#: (the :attr:`VerbatimBlockRef.text` contract, same cap).
text: str = Field(max_length=600_000)
truncated: bool = False
locator: str = Field(max_length=2100)
class ChatDoneEvent(BaseModel):
"""Final SSE event of a chat turn: metadata for the finished answer.