phase: 192_verbatim_inline_midturn
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:
+59
-9
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user