6.6 KiB
6.6 KiB
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
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, sochat_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 freshScaffoldingFilter, pass it tochat_stream_retried(..., scaffolding=round_filter), and count the round's visible content (sum of the lengths of the yieldedStreamPiece("content", …)texts — the filtered ones). After the round, whennot 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) — onechat_stream_retriedrequest withtools=None, a fresh filter, the sameretries/delaybudget; 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 astripped_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 callsexit only (pinned).
app/api/chat.py— the deflected path (the grounded path is covered byrun_agent):- Create one
ScaffoldingFilterfor the turn's request, pass it tochat_stream_retried(..., scaffolding=filter); count visible content across the piece loop (acontent_charscounter next tothinking_chars). - After the piece loop (deflected branch only): content == 0 and
filter.stripped_chars > 0→ one recovery request: the samemessageswith the system prompt extended byCORRECTION_INSTRUCTION(import fromapp.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 normaldoneflow; else → the terminal path below. - Catch
MalformedReplyErrorbefore the genericLLMErrorhandler:settled = True, yieldChatErrorEvent(detail="The model returned a malformed reply — please try again."), return (noquery_logrow, nodone— the existing terminal-error semantics; the generic "dropped the connection" copy stays for transport failures). - Per-turn log line: append
scaffold_stripped=Nafterretries=N— the sum across the turn's requests (rounds + any recovery; 0 on clean turns — uniform field, the phase-67retries=Npattern). The recovery does not bumpretries=N(it is not a phase-67 endpoint-retry). - Module docstring: the phase-71 paragraph (deterministic guardrail + recovery + log field).
- Create one
tests/unit/test_agent.py— the grounded matrix (scripted fake LLM, monkeypatched DB): scaffolding-only round → exactly two model requests, the secondtools=NonewithCORRECTION_INSTRUCTIONin its system prompt → clean answer ends the turn,done-side state normal (holder untouched by the recovery); scaffolding twice →MalformedReplyError(assert it subclassesLLMError); 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.tests/integration/test_chat_api.py— the deflected matrix overPOST /api/chat(mock LLM): scaffolding-only deflected turn → clean recovery answer +doneframe; scaffolding twice → the error frame with the dedicated copy, nodone, noquery_logrow (assert against the table); mixed scaffolding+content → clean answer,scaffold_stripped>0in the log line; the log-line pin gainsscaffold_stripped=0on 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-covgreen.- Exactly one recovery per turn, on both paths; the recovery request is
tools=None+ correction line; a second empty reply → the dedicated error frame, noquery_logrow. - 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 pyrightclean.