feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery
This commit is contained in:
@@ -127,6 +127,35 @@ Implements just enough of the aipi surface:
|
||||
specific phrase — same convention as ``think in paragraphs``); no
|
||||
existing E2E question or fixture file contains the trigger, so
|
||||
every other suite is unaffected.
|
||||
- user message containing ``emit raw tool markup``
|
||||
(``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails — the
|
||||
2026-09-03 incident where a deflected round streamed the model's
|
||||
raw ``<|tool_call_start|>…<|tool_call_end|>`` markup into the UI)
|
||||
**or** ``always emit raw tool markup``
|
||||
(``SCAFFOLD_ALWAYS_TRIGGER``, checked FIRST — it contains the
|
||||
former phrase) -> the deterministic SCAFFOLDING flow, independent
|
||||
of the ``<tools>`` marker (both grounded and deflected turns hit
|
||||
it):
|
||||
* ``SCAFFOLD_ALWAYS_TRIGGER``: EVERY request (the one bounded
|
||||
recovery included) streams ONLY ``delta.content`` chunks
|
||||
carrying the incident span ``SCAFFOLD_SPAN`` —
|
||||
``<|tool_call_start|>[read(path='search_docs/reese-notes.md')]
|
||||
<|tool_call_end|>`` — split across the mock's 12-char chunks
|
||||
(the filter's boundary path), ``finish_reason: "stop"``, no
|
||||
structured ``tool_calls``, no reasoning — the terminal
|
||||
malformed-reply path.
|
||||
* ``SCAFFOLD_TRIGGER``: request 1 (no ``CORRECTION_INSTRUCTION``
|
||||
in the system prompt) streams the same scaffolding-only span;
|
||||
request 2 (the system prompt carries the harness constant — a
|
||||
stable substring of ``app.rag.agent.CORRECTION_INSTRUCTION``,
|
||||
IMPORTED into this module so the mock can never drift from
|
||||
it: the one bounded recovery, ``tools=None`` with the
|
||||
correction folded into the single system prompt) streams the
|
||||
clean ``SCAFFOLD_RECOVERY_ANSWER`` — the recovery path.
|
||||
Checked BEFORE the ``SEARCH_TRIGGER`` / ``TOOLS_TRIGGER`` flows
|
||||
(the trigger needs no ``<tools>`` section); no existing E2E
|
||||
question or fixture file contains the phrase, so every other
|
||||
suite is unaffected.
|
||||
- user message containing ``show me a table`` (phase 44, markdown
|
||||
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
||||
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
||||
@@ -188,6 +217,8 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.rag.agent import CORRECTION_INSTRUCTION # phase 71: the harness constant
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
DIM = 768
|
||||
@@ -364,6 +395,51 @@ ALWAYS_FAIL_TRIGGER = "always fail"
|
||||
#: bag-of-words vector — the endpoint's pre-stream embedding retry loop.
|
||||
EMBED_FAIL_TRIGGER = "embed fail once"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 71 (tool-scaffolding guardrails, 2026-09-03 incident):
|
||||
# deterministic raw-markup flows — see the module docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) drives
|
||||
#: the scaffolding flow: request 1 streams ONLY the incident's raw tool
|
||||
#: markup as ``delta.content``; the follow-up request carrying the
|
||||
#: harness correction in the system prompt (the one bounded recovery)
|
||||
#: streams the clean answer. Independent of the ``<tools>`` marker —
|
||||
#: both grounded and deflected turns hit it. Existing E2E questions do
|
||||
#: not contain the phrase, so every other suite is unaffected.
|
||||
SCAFFOLD_TRIGGER = "emit raw tool markup"
|
||||
|
||||
#: A user message containing this substring (checked BEFORE
|
||||
#: ``SCAFFOLD_TRIGGER`` — it contains that phrase) streams the
|
||||
#: scaffolding-only span on EVERY request, recovery included — the
|
||||
#: terminal malformed-reply path (the dedicated error frame, no done).
|
||||
SCAFFOLD_ALWAYS_TRIGGER = "always emit raw tool markup"
|
||||
|
||||
#: The incident span (2026-09-03): the model's chat-template tool
|
||||
#: syntax, emitted as plain ``delta.content`` although no tools were
|
||||
#: offered. Streamed through the mock's 12-char chunking, so it always
|
||||
#: spans ≥2 wire chunks (the filter's boundary path).
|
||||
SCAFFOLD_SPAN = (
|
||||
"<|tool_call_start|>[read(path='search_docs/reese-notes.md')]"
|
||||
"<|tool_call_end|>"
|
||||
)
|
||||
|
||||
#: The clean answer the one bounded recovery produces (byte-stable —
|
||||
#: the dedicated E2E suite asserts the recovered bubble and the wire's
|
||||
#: delta text against it).
|
||||
SCAFFOLD_RECOVERY_ANSWER = "Here is the plain-text answer the recovery produced."
|
||||
|
||||
#: The stable substring of the harness-owned correction constant the
|
||||
#: recovery request carries in its system prompt. Keyed on a substring
|
||||
#: (not the whole constant) so a re-wrap of the constant cannot silently
|
||||
#: re-route the mock; the module-level assert below fails loudly if the
|
||||
#: substring ever leaves the constant (the mock must never drift from
|
||||
#: ``app.rag.agent.CORRECTION_INSTRUCTION``).
|
||||
_CORRECTION_MARKER = "no tool syntax"
|
||||
assert _CORRECTION_MARKER in CORRECTION_INSTRUCTION, (
|
||||
"mock drift: the correction marker left CORRECTION_INSTRUCTION"
|
||||
)
|
||||
|
||||
#: One DEAD app-level chat attempt costs exactly this many HTTP POSTs
|
||||
#: while the endpoint stays down: the openai SDK's default policy
|
||||
#: (max_retries=2 — the app's ``LLMClient`` keeps it) re-POSTs a 500'd
|
||||
@@ -535,6 +611,35 @@ def _search_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
return ("search",)
|
||||
|
||||
|
||||
def _scaffold_flow(body: dict[str, Any]) -> str | None:
|
||||
"""Classify a phase-71 scaffolding request (see the module docstring).
|
||||
|
||||
* ``"scaffold"`` — stream ONLY the incident span
|
||||
(``SCAFFOLD_SPAN``) as ``delta.content`` chunks: ``finish_reason:
|
||||
"stop"``, no structured ``tool_calls``, no reasoning. EVERY
|
||||
request for ``SCAFFOLD_ALWAYS_TRIGGER`` (the recovery included),
|
||||
and the FIRST request of ``SCAFFOLD_TRIGGER`` (no correction in
|
||||
the system prompt yet).
|
||||
* ``"recovery"`` — ``SCAFFOLD_TRIGGER`` whose system prompt carries
|
||||
the harness correction (the one bounded recovery: ``tools=None``,
|
||||
the constant folded into the single system prompt by
|
||||
``app.api.chat`` / ``app.rag.agent``): stream the clean
|
||||
``SCAFFOLD_RECOVERY_ANSWER``.
|
||||
* ``None`` — not the scaffolding flow. The discrimination is
|
||||
stateless, like the other marker flows: the trigger phrase in
|
||||
the user message plus the correction's presence in the system
|
||||
prompt.
|
||||
"""
|
||||
user = _user(body).lower()
|
||||
if SCAFFOLD_ALWAYS_TRIGGER in user: # checked FIRST — it contains SCAFFOLD_TRIGGER
|
||||
return "scaffold"
|
||||
if SCAFFOLD_TRIGGER in user:
|
||||
if _CORRECTION_MARKER in _system(body):
|
||||
return "recovery"
|
||||
return "scaffold"
|
||||
return None
|
||||
|
||||
|
||||
def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a marker request into one step of the tool flow.
|
||||
|
||||
@@ -1061,6 +1166,29 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS):
|
||||
return _llm_500(RETRY_TRIGGER)
|
||||
_fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart
|
||||
# Phase 71 (tool-scaffolding guardrails): the deterministic raw-
|
||||
# markup flow — checked BEFORE the search/tool marker flows (the
|
||||
# trigger is independent of the ``<tools>`` marker, so both
|
||||
# grounded and deflected turns hit it; SCAFFOLD_ALWAYS_TRIGGER
|
||||
# is checked first inside the classifier — the more specific
|
||||
# phrase wins, same convention as THINK_PARAS_TRIGGER).
|
||||
scaffold_flow = _scaffold_flow(body)
|
||||
if scaffold_flow is not None:
|
||||
# Request 1 (or EVERY request for the ALWAYS trigger): the
|
||||
# incident span as plain delta.content, 12-char chunks
|
||||
# (the span always spans ≥2 chunks — the filter's boundary
|
||||
# path), finish_reason "stop", no tool_calls, no reasoning.
|
||||
# Request 2 of the recovery trigger: the clean answer.
|
||||
answer = (
|
||||
SCAFFOLD_RECOVERY_ANSWER
|
||||
if scaffold_flow == "recovery"
|
||||
else SCAFFOLD_SPAN
|
||||
)
|
||||
return StreamingResponse(
|
||||
_sse_stream(answer, 0.0),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 68 (search tool): the deterministic search marker flow —
|
||||
# checked BEFORE the phase-37 tool flow (the more specific
|
||||
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
|
||||
|
||||
Reference in New Issue
Block a user