feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery
This commit is contained in:
+48
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user