feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)

This commit is contained in:
2026-09-03 11:17:47 -04:00
parent 16f1cfbcaf
commit 801639efcc
55 changed files with 4031 additions and 1466 deletions
@@ -0,0 +1,152 @@
# Phase 71 — Tool-Scaffolding Guardrails: Deterministic Strip + One Bounded Recovery
**Source:** owner request (chat, 2026-09-03) — same incident as phase 70: the
deflected answer streamed the raw model text
`<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>` into
the UI (the model's own `<|…|>` chat-template tool syntax, emitted as plain
`delta.content` even though no tools were offered). Owner direction: "We also need
guardrails for situations like this… **deterministic guardrails only** right now,
forget using a model for that" — no model of any kind (no `lite` classifier, no
model-authored repair) in the guardrail path; `lite` stays the chat model for
everything (it's faster).
**Story:** n/a (owner request from chat — tool-scaffolding guardrails, 2026-09-03)
**Context:**
- `app/rag/llm.py` — `chat_stream` yields `StreamPiece("content", delta.content)`
verbatim (L~`content = delta.content; if content: yield`); thinking pieces
(`delta.reasoning_content`) pass through raw by design; tool-call materialization
happens at stream end (after the `async for`); phase-48 teardown closes the
endpoint stream on every exit (must stay intact). `chat_stream_retried` (phase 67)
wraps it with the retry-before-first-piece rule.
- `app/rag/agent.py` — `run_agent`'s round loop: a round with no `ToolCallPiece`s
ends the turn (`if not calls: return`); the round cap forces one final
`tools=None` request (the "forced final answer" pattern this phase reuses for
recovery); `AgentHolder` (read_docs / tool_calls) is unchanged.
- `app/api/chat.py` — the piece loop (thinking/tool/retry/delta handling +
`thinking_chars` counter), the deflected path (`chat_stream_retried(...,
tools=None)` directly), the `LLMError` → terminal error-frame handler, the
per-turn log line (ends `…total_ms=N retries=N`).
- `app/rag/prompts.py` — `build_deflect_prompt`'s `DEFLECT_MODE` body (the E2E mock
keys on the marker's *presence*, not the wording — an appended line is safe).
- `tests/e2e/mock_llm.py` — the trigger-string flow table (task 05 adds the
scaffolding triggers; phase-70 names apply).
- Tests to extend: `tests/unit/test_llm_client.py`, `test_agent.py`,
`tests/integration/test_chat_api.py` (log-line + error-frame pins),
`tests/unit/test_prompts.py`.
## Objective
Raw tool-scaffolding tokens can never reach the user as answer text: a deterministic
streaming filter strips known scaffolding from `delta.content` as it flows, and a
round/turn whose visible content ends up empty (scaffolding was the whole "answer")
gets **one** bounded, deterministic recovery (same turn, `tools=None`, a fixed
harness-owned correction line in the system prompt); if the recovery also comes back
empty, the turn settles with a dedicated structured error frame. No model is used to
detect or repair anything.
## Dependencies
- `70_harness_aligned_tools` (todo) — runs first: the mock/prompt/code state this
phase builds on (new tool names in the mock flows and prompt).
- `67_llm_retry` (complete) — `chat_stream_retried`, the primitive every request
(including recoveries) goes through.
- `48_stop_generation` (complete) — the phase-48 stream-teardown contract the
filter integration must not break.
## Tasks
1. `01_filter.md` — `app/rag/scaffolding.py` (new): the pattern registry +
`ScaffoldingFilter` streaming state machine, with the boundary test matrix.
2. `02_llm_integration.md` — `app/rag/llm.py`: `chat_stream`/`chat_stream_retried`
accept the filter; content deltas are filtered, the tail flushed before tool
materialization; `None` = byte-identical raw path.
3. `03_recovery_policy.md` — `app/rag/agent.py` + `app/api/chat.py`: the empty-round
recovery (one per turn, `tools=None`, correction constant),
`MalformedReplyError`, the dedicated error copy, and the per-turn log line's
`scaffold_stripped=N` field.
4. `04_deflect_prompt.md` — the plain-text line in the `DEFLECT_MODE` body
(prevention; owner-permitted LOW-prompt change).
5. `05_e2e_commit.md` — the mock scaffolding triggers, the dedicated E2E suite,
full gates, commit.
## Testing & Quality
- Unit (new `tests/unit/test_scaffolding_filter.py`): the filter matrix — span in
one chunk; span split across chunks at **every** boundary offset of the start
token; multiple spans in one chunk; standalone `<|tool_calls|>` / `<|tool_call|>`
tokens stripped; look-alikes **not** stripped (prose containing the words
"tool_call" or `tool_call_start` without the `<|…|>` delimiters, an unknown
`<|some_other_token|>`, a lone `<|tool_call_end|>` without a start); a partial
start token at stream end → `flush()` emits it as-is (no false-positive strip);
`stripped_chars` accounting; empty chunks.
- Unit: `tests/unit/test_llm_client.py` — `chat_stream` with a filter (content
filtered, thinking raw, `None` = raw pass-through pinned byte-identical, flush
order: flushed tail content precedes tool-call pieces, phase-48 teardown intact).
- Unit: `tests/unit/test_agent.py` — grounded recovery matrix (scaffolding-only
round → exactly one recovery request: `tools=None` + correction line in the
system prompt + fresh filter → clean answer ends the turn; scaffolding twice →
`MalformedReplyError`; scaffolding + real content → clean answer, **no**
recovery; round cap and kill switch unchanged).
- Integration: `tests/integration/test_chat_api.py` — deflected-path recovery matrix
(same shapes over the SSE endpoint: clean recovery → `done` frame; terminal → the
dedicated error frame, no `done`, no `query_log` row — same terminal semantics as
today's `LLMError`); log line carries `scaffold_stripped=N` (0 when nothing was
stripped — the field is uniform, the phase-67 `retries=N` pattern).
- E2E (mandatory, house rule): NEW dedicated suite
`tests/e2e/test_tool_scaffolding_guardrails.py`, run in isolation — recovery case
(raw tokens never in the DOM, clean answer shown) and terminal case (error state,
no raw tokens, the app stays usable).
- Coverage: **>90%** on `app/` (validate.sh gate).
## Completion Criteria
- [ ] `rg "tool_call_start" frontend/` → no matches (no scaffolding rendering
path); the filter lives in `app/rag/scaffolding.py` as a pure module (no I/O,
no model calls).
- [ ] A content stream of pure scaffolding yields zero `delta` frames; a mixed
stream yields the clean remainder; thinking frames are never filtered.
- [ ] Exactly one recovery per turn (grounded and deflected paths); the recovery
request is `tools=None` with the fixed correction line in the system prompt;
a second empty reply settles with the error frame
"The model returned a malformed reply — please try again."
- [ ] The per-turn log line ends `…retries=N scaffold_stripped=N`; `scaffold_stripped=0`
on clean turns (uniform field).
- [ ] `uv run pytest` green; `uv run pytest --cov=app` TOTAL **>90%**;
`uv run ruff check . && uv run pyright` clean.
- [ ] `uv run pytest tests/e2e/test_tool_scaffolding_guardrails.py -v --no-cov`
green in isolation; regression suites green in isolation:
`test_harness_aligned_tools.py`, `test_chat_rag.py`, `test_agent_document_tools.py`.
- [ ] One `--no-gpg-sign` commit (message in the Commit block); phase dir moved to
`.agent/phases/complete/`.
## Locked decisions
- **Owner (chat, 2026-09-03): deterministic only.** No model — `lite` or any other —
participates in detection or repair. The guardrail is a fixed pattern registry + a
fixed retry policy. (A model-based classifier/repair was proposed and explicitly
rejected for now — if it is ever wanted, it is a later phase with its own
permission.)
- **`lite` stays the chat model** (owner: "I want to use lite for everything since
it's way faster") — the guardrail is what protects the UX while `lite` is the
model; no `.env` change in this phase.
- **The pattern registry is the extension point.** Initial entries: the observed
span form `<|tool_call_start|>…<|tool_call_end|>` (non-greedy, any text between)
plus the standalone sibling tokens `<|tool_calls|>` and `<|tool_call|>` from the
same tokenizer family. Every strip logs a warning with the stripped span
(truncated to 200 chars) — that log line is how a new format gets captured and
added (pattern + unit fixture), keeping the registry honest (every entry traces
to an observed capture or the initial incident).
- **Content only, thinking never filtered.** The Thinking block is the model's raw
reasoning by design (phase 17) and stays raw (collapsible); the guardrail protects
the answer, not the scratchpad.
- **Recovery is a fixed policy, not a conversation.** One extra request per turn,
same messages with the harness-owned constant folded into the system prompt
(single system message — provider-safe), `tools=None`, a fresh filter, the same
phase-67 retry budget. At most one recovery; the second empty reply is terminal.
A round with real visible content plus scaffolding needs no recovery (the clean
content stands).
- **Terminal semantics follow the existing error pattern.** A terminal malformed
turn settles with a structured `error` frame (dedicated copy), writes no
`query_log` row, and the UI shows the existing error state — byte-for-byte the
same shape as today's `LLMError` terminal path.
- **`MalformedReplyError` subclasses `LLMError`** and is raised only by the recovery
policy (never from inside a stream, so `chat_stream_retried`'s retry rule never
sees it); `chat.py` catches it before the generic `LLMError` handler.
## Commit
```bash
git add -A .agent/ app/ tests/ frontend/ && git commit --no-gpg-sign -m "feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery"
```
@@ -0,0 +1,77 @@
# Task 01 — `app/rag/scaffolding.py`: Pattern Registry + Streaming Filter
**Phase:** `71_scaffolding_guardrails` · **Story:** n/a (owner request from chat, 2026-09-03)
## Objective
A pure-Python streaming filter that removes known tool-scaffolding from answer text
as it flows — no I/O, no model, fully unit-testable — plus the pattern registry that
defines "known" (every entry traces to an observed capture).
## Work
1. `app/rag/scaffolding.py` (new module):
- `SCAFFOLD_PATTERNS: tuple[re.Pattern, ...]` — module-level, compiled:
1. `re.compile(r"<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>")` — the
observed span (incident 2026-09-03), non-greedy so multiple spans each
strip to their own end token.
2. `re.compile(r"<\|tool_calls\|>")` — standalone sibling token (same
tokenizer family).
3. `re.compile(r"<\|tool_call\|>")` — standalone sibling token.
Module docstring: the registry is the extension point — a new entry needs an
observed capture (the strip warning log, task 03) + a unit fixture here; no
speculative entries.
- `class ScaffoldingFilter` — streaming state machine over a **content** stream:
- `feed(chunk: str) -> str` — appends to an internal pending buffer and
returns the clean text safe to emit now. Algorithm: repeatedly (a) take the
leftmost complete match among `SCAFFOLD_PATTERNS` in the pending buffer —
drop it (count into `stripped_chars`) and continue; then (b) check the
buffer tail for a **live prefix**: the longest suffix that is either a
proper prefix of any pattern's literal opening (`<|tool_call_start|>`,
`<|tool_calls|>`, `<|tool_call|>`) or an **open span** (a start token with
no end token yet in the buffer — everything from that start token onward is
held, since the span may continue in future chunks). Emit everything before
the held tail; keep the held tail as the new pending state. (Bounded: the
held tail in NORMAL state is ≤ the longest opening token minus one char;
in an open span it is unbounded but is being *stripped*, never emitted.)
- `flush() -> str` — end of stream: the pending tail is emitted **as-is**
(a partial marker at EOF is content, not scaffolding — a documented
choice, pinned).
- `stripped_chars: int` — total characters removed (property or attribute;
read by the caller after the round/turn, task 03).
- One filter instance per model request (callers create fresh instances —
the phase-67 retry-after-dead-attempt case is safe: a dead attempt never
fed the filter).
- No logging, no settings, no imports beyond `re` (pure module; the strip
*warning* log is emitted by the integration layer, task 03, so the module
stays I/O-free).
2. `tests/unit/test_scaffolding_filter.py` (new) — the matrix from the phase
overview:
- span in one chunk → stripped, `stripped_chars` exact.
- span split across chunks: feed the incident text
`<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>`
at **every** split offset of the start token (0..len) plus a mid-span split
and an end-token split → nothing ever emits until the span completes; total
output empty; `stripped_chars` = full span length.
- two spans in one chunk (with text between) → between-text emitted, spans
stripped.
- standalone `<|tool_calls|>` / `<|tool_call|>` stripped (alone and embedded in
a line).
- look-alikes **not** stripped, emitted verbatim: `tool_call_start` as prose;
`<|tool_call_start|` (missing delimiter close) as prose; an unknown
`<|some_other_token|>`; a lone `<|tool_call_end|>` without a start.
- partial start token at EOF (`feed("<|tool_call_st")` then `flush()`) →
flush emits it as-is, `stripped_chars == 0`.
- surrounding text preserved exactly: `hello <|…span…> world` → `hello world`
(no reflow beyond the removal); empty chunk no-op; `flush()` on a clean
stream emits nothing new.
## Testing & Quality
- Unit: `tests/unit/test_scaffolding_filter.py` (above) — exhaustive boundary
offsets, parametrized.
- Coverage: **>90%** on `app/rag/scaffolding.py` (every branch: match, open-span
hold, live-prefix hold, flush-emits-as-is).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_scaffolding_filter.py -v --no-cov` green.
- [ ] `rg "import (os|sys|logging|app)" app/rag/scaffolding.py` → no matches (pure
module).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,61 @@
# Task 02 — Filter Integration in `chat_stream` / `chat_stream_retried`
**Phase:** `71_scaffolding_guardrails` · **Story:** n/a (owner request from chat, 2026-09-03)
## Objective
Route `delta.content` through the caller-supplied `ScaffoldingFilter` in
`app/rag/llm.py` — content filtered, thinking raw, tail flushed before tool-call
materialization — with `None` keeping today's byte-identical raw path.
## Work
1. `app/rag/llm.py` — `chat_stream(..., scaffolding: "ScaffoldingFilter | None =
None)"` (type imported under `TYPE_CHECKING` or as a string annotation to keep
the module's import graph clean — the filter type is only needed for typing):
- Content path: `content = delta.content` → when a filter is present,
`cleaned = scaffolding.feed(content)`; yield `StreamPiece("content",
cleaned)` only when `cleaned` is non-empty (an empty clean result yields
**nothing** — no empty `delta` frames). Without a filter the existing
`if content: yield` stands untouched (byte-identical).
- Thinking path: unchanged — `reasoning_content` pieces are never filtered
(locked: the scratchpad stays raw).
- End of stream: after the `async for` exhausts and **before** the tool-call
materialization block (`if calls and not emitted: …` and the
finish-reason emission), `tail = scaffolding.flush()` when a filter is
present; yield a content piece for the tail when non-empty. Order pinned:
flushed-tail content precedes `ToolCallPiece`s (content-before-tools wire
convention).
- Teardown (phase 48) untouched: the `finally: await stream.close()` behavior is
independent of the filter.
- Docstring: a short "Scaffolding guardrail (phase 71)" paragraph — the filter
is caller-owned (one per request), content-only, `None` = raw path.
2. `app/rag/llm.py` — `chat_stream_retried(..., scaffolding=None)` — pass-through
to `llm.chat_stream`. The same filter object is used across retry attempts of
one logical request: safe by construction (a restarted attempt only happens
when no piece was emitted, i.e. the filter was never fed — note this in the
docstring).
3. `tests/unit/test_llm_client.py` — scripted-chunk tests (the existing
fake-client pattern):
- with a filter: a chunk carrying a span mid-stream → the `delta` pieces carry
only the clean text; a span split across two chunks → no partial emit;
`filter.stripped_chars` correct after consumption.
- thinking pieces pass through raw even when they contain a span (pinned).
- `scaffolding=None` → the yielded pieces are byte-identical to the pre-phase
raw path (a span in content is yielded verbatim — the raw contract for
callers that opt out).
- flush ordering: a trailing partial-then-complete tail yields its content
piece **before** the materialized `ToolCallPiece` (a chunk stream that ends
with `…clean tail` + tool_calls deltas).
- teardown interaction: the phase-48 `aclose()`/abandon tests
(`tests/unit/test_llm_stream_teardown.py`) stay green with and without a
filter (run, don't rewrite).
## Testing & Quality
- Unit: `tests/unit/test_llm_client.py` (+ the untouched teardown suite green).
- Coverage: **>90%** on this task's modified code (`app/rag/llm.py`).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_llm_client.py tests/unit/test_llm_stream_teardown.py
-v --no-cov` green.
- [ ] `scaffolding=None` callers (including the kill-switch no-tools requests) are
byte-identical (pinned).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,104 @@
# Task 03 — Recovery Policy: One Bounded `tools=None` Retry + Terminal Error
**Phase:** `71_scaffolding_guardrails` · **Story:** n/a (owner request from chat, 2026-09-03)
## Objective
When a round/turn's visible content ends up empty **because scaffolding was the
whole answer**, run exactly one deterministic recovery (same turn, `tools=None`,
fixed correction line in the system prompt, fresh filter); a second empty reply
settles with a dedicated error frame. Both the grounded agent loop and the
deflected path get the policy; the per-turn log line gains `scaffold_stripped=N`.
## Work
1. `app/rag/agent.py`:
- `CORRECTION_INSTRUCTION: str` — the harness-owned constant (verbatim, single
line): "Your previous reply contained raw tool-call markup, which is not
interpreted here. Answer the user's question directly in plain text — no
tool syntax." (The E2E mock in task 05 keys on a stable substring of it —
pick the exact constant now; the mock copies it.)
- `class MalformedReplyError(LLMError)` — raised only by the recovery policy
(never from inside a stream, so `chat_stream_retried`'s retry rule never
sees it). Module docstring: the phase-71 note (deterministic-only, owner
permission 2026-09-03).
- `run_agent` — per round: create a fresh `ScaffoldingFilter`, pass it to
`chat_stream_retried(..., scaffolding=round_filter)`, and count the
round's visible content (sum of the lengths of the yielded
`StreamPiece("content", …)` texts — the filtered ones). After the round,
when `not calls` (today's "the answer was streamed" exit):
- if round content > 0 → return (as today).
- if round content == 0 and `round_filter.stripped_chars > 0` → **one
recovery**: `messages_recovered = [*messages[:-1], {"role": "system",
"content": system_prompt + "\n" + CORRECTION_INSTRUCTION}, messages[-1]]`
(the correction folds into the ORIGINAL single system message —
provider-safe; the user message stays last) — one
`chat_stream_retried` request with `tools=None`, a fresh filter, the same
`retries`/`delay` budget; yield its pieces through the normal piece flow.
If the recovery's visible content > 0 → return. Otherwise →
`logger.warning` + `raise MalformedReplyError(…)`.
- if round content == 0 and nothing was stripped → return (today's
empty/thinking-only answer behavior — the UI handles it; unchanged).
- Log one warning per strip event here: `logger.warning("agent: stripped
N chars of tool-scaffolding in round %d: %r", …)` with the stripped span
truncated to 200 chars (the capture mechanism for new registry entries —
the filter exposes the stripped spans for this; add a
`stripped_spans: list[str]` to the filter if needed).
- A scaffolding-only round that also **carried tool calls** needs no
recovery (the clean content stands / the tool ran) — the policy keys on
the `not calls` exit only (pinned).
2. `app/api/chat.py` — the **deflected** path (the grounded path is covered by
`run_agent`):
- Create one `ScaffoldingFilter` for the turn's request, pass it to
`chat_stream_retried(..., scaffolding=filter)`; count visible content across
the piece loop (a `content_chars` counter next to `thinking_chars`).
- After the piece loop (deflected branch only): content == 0 and
`filter.stripped_chars > 0` → one recovery request: the same
`messages` with the system prompt extended by
`CORRECTION_INSTRUCTION` (import from `app.rag.agent`), `tools=None`, a
**fresh** filter, the same retry budget; stream its pieces through the
SAME piece-handling code (extract the piece loop into a small inner
helper/coroutine to avoid duplicating the thinking/tool/retry/delta
handling — the extraction must be behavior-preserving for the first pass,
pinned by the existing integration suite). If the recovery content > 0 →
continue to the normal `done` flow; else → the terminal path below.
- Catch `MalformedReplyError` **before** the generic `LLMError` handler:
`settled = True`, yield `ChatErrorEvent(detail="The model returned a
malformed reply — please try again.")`, return (no `query_log` row, no
`done` — the existing terminal-error semantics; the generic
"dropped the connection" copy stays for transport failures).
- Per-turn log line: append `scaffold_stripped=N` after `retries=N` — the sum
across the turn's requests (rounds + any recovery; 0 on clean turns —
uniform field, the phase-67 `retries=N` pattern). The recovery does not
bump `retries=N` (it is not a phase-67 endpoint-retry).
- Module docstring: the phase-71 paragraph (deterministic guardrail +
recovery + log field).
3. `tests/unit/test_agent.py` — the grounded matrix (scripted fake LLM,
monkeypatched DB): scaffolding-only round → exactly **two** model requests,
the second `tools=None` with `CORRECTION_INSTRUCTION` in its system prompt →
clean answer ends the turn, `done`-side state normal (holder untouched by the
recovery); scaffolding twice → `MalformedReplyError` (assert it subclasses
`LLMError`); scaffolding + real content → one request only, clean content
yielded, no recovery; clean turn → one request, no correction in any system
prompt; the round cap + kill-switch tests stay green.
4. `tests/integration/test_chat_api.py` — the deflected matrix over
`POST /api/chat` (mock LLM): scaffolding-only deflected turn → clean recovery
answer + `done` frame; scaffolding twice → the error frame with the dedicated
copy, no `done`, no `query_log` row (assert against the table); mixed
scaffolding+content → clean answer, `scaffold_stripped>0` in the log line;
the log-line pin gains `scaffold_stripped=0` on clean turns and the summed
value on stripped turns.
## Testing & Quality
- Unit: `tests/unit/test_agent.py` (grounded matrix).
- Integration: `tests/integration/test_chat_api.py` (deflected matrix + log line).
- Coverage: **>90%** on this task's modified code (`app/rag/agent.py`,
`app/api/chat.py`).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_agent.py tests/integration/test_chat_api.py -v
--no-cov` green.
- [ ] Exactly one recovery per turn, on both paths; the recovery request is
`tools=None` + correction line; a second empty reply → the dedicated error
frame, no `query_log` row.
- [ ] Clean turns: no correction in any system prompt, `scaffold_stripped=0`,
request counts unchanged (kill-switch/deflection byte-identical pins green).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,35 @@
# Task 04 — Deflect Prompt: Plain-Text-Only Line (Prevention)
**Phase:** `71_scaffolding_guardrails` · **Story:** n/a (owner request from chat, 2026-09-03)
## Objective
Close the door at the prompt: the deflected (LOW) turn offers no tools, so any tool
markup there is always wrong — add one plain-text-only instruction line to the
`DEFLECT_MODE` body. This is the owner-permitted change to the otherwise-locked LOW
prompt; the `DEFLECT_MODE` marker and everything else in the prompt stay put (the
E2E mock keys on the marker's presence, not the wording).
## Work
1. `app/rag/prompts.py` — `build_deflect_prompt`: append one line to the
`DEFLECT_MODE` body (after "…propose 2-3 alternative questions."):
"Reply in plain text only — you have no tools in this mode." The weak-hit
title list follows exactly as today; `build_high_prompt`, `PERSONA`,
`TOOLS_SECTION`, and the section order are untouched. Update the module
docstring: the phase-71 note (owner-permitted 2026-09-03 LOW-prompt line;
the marker-keying contract unchanged).
2. `tests/unit/test_prompts.py` (and `test_chat_gate.py` where the deflection body
is pinned) — pins: the new line present in the LOW prompt; the `DEFLECT_MODE`
marker still present (mock keying); the HIGH prompt unchanged (byte-identical
pins stay green — the line must not leak into HIGH); the byte-identical-when-
empty steering/overview behavior unchanged.
## Testing & Quality
- Unit: the prompt pins above.
- Coverage: **>90%** on this task's modified code (`app/rag/prompts.py`).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_prompts.py tests/unit/test_chat_gate.py -v
--no-cov` green.
- [ ] LOW prompt = pre-phase text + exactly the one new line (a diff pin, or a
reconstruction pin in the test).
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,77 @@
# Task 05 — Mock Triggers, Dedicated E2E Suite, Gates, Commit
**Phase:** `71_scaffolding_guardrails` · **Story:** n/a (owner request from chat, 2026-09-03)
## Objective
Prove the guardrail end-to-end through the real UI: a mock-LLM scaffolding flow
(recovery case + terminal case), a dedicated Playwright suite pinning that raw
tokens never reach the DOM, then the full quality gates and the phase commit.
## Work
1. `tests/e2e/mock_llm.py` — two new deterministic flows (checked in the flow
table **before** the plain `TOOLS_TRIGGER` flow, after `SEARCH_TRIGGER`
ordering rules as they fit — the triggers are independent of the `<tools>`
marker, so both grounded and deflected turns hit them):
- `SCAFFOLD_TRIGGER = "emit raw tool markup"` — request 1 (no correction in
the system prompt): stream ONLY `delta.content` chunks carrying the incident
text `<|tool_call_start|>[read(path='search_docs/reese-notes.md')]<|tool_call_end|>`
(split across ≥2 chunks to exercise the boundary path), `finish_reason:
"stop"`, no structured `tool_calls`, no reasoning. Request 2 (system prompt
contains the stable substring of `CORRECTION_INSTRUCTION` — import it from
`app.rag.agent` so the mock can never drift from the constant): stream a
clean plain answer ("Here is the plain-text answer the recovery produced.")
+ `finish_reason: "stop"`.
- `SCAFFOLD_ALWAYS_TRIGGER = "always emit raw tool markup"` — every request
(recovery included): the same scaffolding-only stream, forever.
- Update the module docstring's flow table + the phase-71 note.
2. `tests/e2e/test_tool_scaffolding_guardrails.py` (NEW — the phase's dedicated
suite, house pattern, run in isolation; DB up, mock LLM):
- **Recovery case** — ask a question containing `SCAFFOLD_TRIGGER`: the turn
settles (the composer re-enables, `done` observed); the final answer bubble
contains the recovery's clean text; `document.body.innerText` contains
**neither** `tool_call_start` nor `tool_call_end` (nor the raw
`[read(path=…]` fragment); no error banner.
- **Terminal case** — ask a question containing
`SCAFFOLD_ALWAYS_TRIGGER`: the existing error status renders with the
dedicated copy ("The model returned a malformed reply — please try
again."); no raw tokens in the DOM; no answer bubble with the scaffolding;
the app stays usable — a follow-up plain question (no trigger) gets a
normal streamed answer in the same session.
- **No false positive** — a plain question (existing `CHAT_TRIGGER`-style
flow, no tools needed): the answer streams byte-clean, no error state, no
recovery request visible (the turn settles on the first request).
- The SSE wire itself: in the recovery case, no `delta` frame ever carries a
`tool_call_start`/`tool_call_end` fragment (the existing SSE-capture house
pattern) — the strip happens server-side, not in the UI.
3. Gates + commit:
- `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing`
TOTAL **>90%**; `uv run ruff check . && uv run pyright` clean.
- E2E in isolation (DB up): `test_tool_scaffolding_guardrails.py`, then the
regression suites `test_harness_aligned_tools.py`, `test_chat_rag.py`,
`test_agent_document_tools.py`.
- One atomic commit (message below); move
`.agent/phases/todo/71_scaffolding_guardrails/` →
`.agent/phases/complete/71_scaffolding_guardrails/`.
## Testing & Quality
- E2E: the new dedicated suite (recovery / terminal / no-false-positive / wire
pins) + the three regression suites (isolation runs).
- Coverage: **>90%** on `app/` (phase-level gate).
## Completion Criteria
- [ ] `rg "tool_call_start|tool_call_end" frontend/` → no matches.
- [ ] Raw scaffolding is never visible in the DOM in any case (recovery,
terminal, mixed) — pinned by the dedicated suite.
- [ ] Exactly one recovery per malformed turn (mock request counts implied by the
flows); clean turns never carry the correction line (pinned by the unit
suites from task 03).
- [ ] `uv run pytest` green; `uv run pytest --cov=app` TOTAL **>90%**;
`uv run ruff check . && uv run pyright` clean.
- [ ] `uv run pytest tests/e2e/test_tool_scaffolding_guardrails.py -v --no-cov`
green in isolation; regression suites green in isolation.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ app/ tests/ frontend/ && git commit --no-gpg-sign -m "feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery"
```