feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery

This commit is contained in:
2026-09-03 13:39:15 -04:00
parent 801639efcc
commit 575d6c88d0
38 changed files with 2793 additions and 50 deletions
+48 -16
View File
@@ -23,13 +23,18 @@ import json
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from openai import AsyncOpenAI, AsyncStream
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
from app.config import Settings, get_settings
if TYPE_CHECKING:
# Phase 71: the filter type is only needed for typing (the module
# stays import-graph-clean; callers pass their own instances).
from app.rag.scaffolding import ScaffoldingFilter
logger = logging.getLogger("app.llm")
@@ -338,6 +343,7 @@ class LLMClient:
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
scaffolding: ScaffoldingFilter | None = None,
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
@@ -365,13 +371,26 @@ class LLMClient:
``id`` and ``function.name`` on the first partial and
``function.arguments`` in fragments — which are accumulated into
one :class:`ToolCallPiece` per call, yielded in index order at
stream end (or immediately once a chunk carries
``finish_reason="tool_calls"``). Malformed ``arguments`` JSON
raises :class:`LLMError`. Wire convention verified live against
stream end (after the stream's chunks are exhausted — aipi ends
the stream at ``finish_reason="tool_calls"``, so this is the
wire's emission point). Malformed ``arguments`` JSON raises
:class:`LLMError`. Wire convention verified live against
aipi's ``turbo`` on 2026-08-26 via
``uv run python -m scripts.llm_probe --tools`` (phase 37, task 01:
``probe: turbo tool_calls=supported 2026-08-26``).
Scaffolding guardrail (phase 71): the caller may pass a
``ScaffoldingFilter`` — one per request, caller-owned (this
method never creates or resets one). When present, only
**content** is filtered: ``delta.content`` is fed through the
filter and only the clean text is yielded (an empty clean
result yields **no** piece — no empty ``delta`` frames); thinking
pieces are never filtered (the scratchpad stays raw, phase 17).
At stream end the filter's held tail is flushed to a content
piece **before** any tool-call materialization (content-
before-tools wire convention). ``None`` (the default) keeps
today's byte-identical raw path for callers that opt out.
Any failure (network, HTTP, malformed stream) surfaces as
:class:`LLMError` so the API layer can turn it into an SSE
``error`` event instead of a hung request.
@@ -405,7 +424,6 @@ class LLMClient:
await self._client.chat.completions.create(**kwargs),
)
calls: dict[int, _ToolCallSlot] = {}
emitted = False
async for chunk in stream:
if not chunk.choices:
continue
@@ -436,16 +454,22 @@ class LLMClient:
yield StreamPiece("thinking", reasoning)
content = delta.content
if content:
yield StreamPiece("content", content)
if (
calls
and not emitted
and getattr(choice, "finish_reason", None) == "tool_calls"
):
for piece in _materialize_tool_calls(calls):
yield piece
emitted = True
if calls and not emitted:
if scaffolding is not None:
# Phase 71: content only — an empty clean result
# yields nothing (no empty delta frames).
cleaned = scaffolding.feed(content)
if cleaned:
yield StreamPiece("content", cleaned)
else:
yield StreamPiece("content", content)
# Phase 71: flush the filter's held tail at stream end, BEFORE
# any tool-call materialization — flushed-tail content precedes
# ToolCallPieces (content-before-tools wire convention).
if scaffolding is not None:
tail = scaffolding.flush()
if tail:
yield StreamPiece("content", tail)
if calls:
for piece in _materialize_tool_calls(calls):
yield piece
except LLMError:
@@ -469,6 +493,7 @@ async def chat_stream_retried(
tools: list[dict[str, Any]] | None = None,
retries: int = 0,
delay: float = 0.0,
scaffolding: ScaffoldingFilter | None = None,
) -> AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]:
"""Stream a chat turn, retrying a dead endpoint (phase 67).
@@ -493,6 +518,13 @@ async def chat_stream_retried(
The request is restarted byte-identical: ``chat_stream`` is stateless,
so every attempt is opened with the SAME *messages*/*tools*.
Scaffolding guardrail (phase 71): *scaffolding* is passed through to
every attempt's ``chat_stream``. The SAME caller-owned filter object
across the retry attempts of one logical request is safe by
construction: a restarted attempt only happens while no piece was
emitted, i.e. the filter was never fed (its pending buffer is still
empty).
Teardown (phase 48, extended): every attempt's stream is explicitly
closed in a ``finally`` — normal exhaustion, a terminal
:class:`LLMError`, and a consumer abandon (``GeneratorExit`` mid-attempt
@@ -502,7 +534,7 @@ async def chat_stream_retried(
max_attempts = retries + 1
for attempt in range(1, max_attempts + 1):
emitted = False
stream = llm.chat_stream(messages, tools=tools)
stream = llm.chat_stream(messages, tools=tools, scaffolding=scaffolding)
try:
async for piece in stream:
emitted = True