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
+192 -2
View File
@@ -95,6 +95,27 @@ task 04):
exactly one round. With ``settings.llm_retries=0`` every request is a
single plain attempt (the pre-phase-67 path).
Scaffolding guardrail (phase 71, deterministic only — owner permission
2026-09-03: "deterministic guardrails only right now, forget using a
model for that"): every model request (each round, the forced final,
and any recovery) runs its ``delta.content`` through a fresh caller-
owned :class:`app.rag.scaffolding.ScaffoldingFilter`, so raw
``<|tool_call_start|>…<|tool_call_end|>`` tokens can never reach the
user as answer text. A round that ends with NO visible content AND a
non-empty strip (the scaffolding was the whole "answer") gets exactly
ONE bounded recovery: one extra request with ``tools=None``, the same
messages with :data:`CORRECTION_INSTRUCTION` folded into the original
single system message, a fresh filter, and the same phase-67 retry
budget. A recovery that also comes back empty — or a round with no
strip and no content (today's empty/thinking-only answer) — settles as
before; a second empty reply raises :class:`MalformedReplyError` (the
API layer turns it into the dedicated error frame). A round with real
visible content plus scaffolding needs no recovery (the clean content
stands), and a scaffolding-only round that also carried tool calls
needs none either (the tool ran) — the policy keys on the no-calls
exit only. No model participates in detection or repair: the
registry + the fixed retry policy are the whole guardrail.
The DB accessors (:func:`list_catalog`, :func:`list_source_names`,
:func:`find_document`, :func:`all_documents`) and the
:func:`grep_document` line matcher are module-level functions so unit
@@ -116,11 +137,13 @@ from app.models import Document
from app.rag.git_sources import effective_sources
from app.rag.llm import (
LLMClient,
LLMError,
RetryPiece,
StreamPiece,
ToolCallPiece,
chat_stream_retried,
)
from app.rag.scaffolding import ScaffoldingFilter
from app.rag.source_removal import resolve_source_name
logger = logging.getLogger("app.agent")
@@ -228,6 +251,33 @@ UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
#: The harness-owned recovery line (phase 71, task 03) — folded into the
#: ORIGINAL single system message of the one bounded recovery request
#: (``system_prompt + "\n" + CORRECTION_INSTRUCTION``; provider-safe,
#: the user message stays last). Verbatim constant: the E2E mock
#: (task 05) keys on a stable substring of it, so it must not drift.
CORRECTION_INSTRUCTION: str = (
"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."
)
class MalformedReplyError(LLMError):
"""The model kept replying in raw tool-scaffolding (phase 71).
Raised ONLY by the recovery policy — :func:`run_agent` (grounded
path) and ``app.api.chat`` (deflected path) — when the one bounded
``tools=None`` recovery still comes back with no visible content.
It is never raised from inside a stream, so
:func:`app.rag.llm.chat_stream_retried`'s retry-before-first-piece
rule never sees it. The API layer catches it BEFORE the generic
:class:`LLMError` handler and settles the turn with the dedicated
"malformed reply" error frame (no ``done``, no ``query_log`` row).
Deterministic only (owner permission 2026-09-03): no model
participates in detection or repair.
"""
#: Search caps (owner-locked A5, phase 68): a global per-call match cap
#: (across documents, in catalog order) and a per-match-line char limit.
SEARCH_MAX_MATCHES = 20
@@ -352,10 +402,16 @@ class AgentHolder:
rejected calls (unknown tool, unknown/missing arguments or document,
already-in-context) do not count. Drives the per-turn log line's
``tool_calls=N`` field (task 04).
``scaffold_stripped``: how many chars of tool-scaffolding the
turn's filters removed across the turn's requests (rounds + the
forced final + any recovery, phase 71) — drives the per-turn log
line's ``scaffold_stripped=N`` field on grounded turns (the
deflected path computes its own total in ``app.api.chat``).
"""
read_docs: list[Document] = field(default_factory=list)
tool_calls: int = 0
scaffold_stripped: int = 0
def _execute_tool(
@@ -476,6 +532,24 @@ async def run_agent(
``settings.llm_retry_delay``); a round that already streamed pieces
fails the turn as before.
Scaffolding recovery (phase 71, deterministic only): every request —
each round, the forced final, and any recovery — runs its content
through a fresh :class:`app.rag.scaffolding.ScaffoldingFilter`. A
round that ends with NO visible content but a non-empty strip (the
scaffolding was the whole "answer") gets exactly ONE recovery:
``tools=None``, :data:`CORRECTION_INSTRUCTION` folded into the
original single system message (the rest of the history — user
message and tool results — unchanged), a fresh filter, the same
retry budget. A clean recovery ends the turn; a second empty reply
raises :class:`MalformedReplyError` (terminal — the API layer turns
it into the dedicated error frame). A round with visible content
plus scaffolding needs no recovery (the clean content stands), and
a scaffolding-only round that also carried tool calls needs none
(the tool ran) — the policy keys on the no-calls exit only. The
per-span strip warning log (each span truncated to 200 chars) is
the capture mechanism for new registry entries; *holder* accumulates
the turn's ``scaffold_stripped`` total for the API layer's log line.
``seed_docs`` are the documents the retrieval already put in context
(they shape the *system_prompt* the caller built); re-reading one of
them is rejected as "Already in your context." — the rejection counts
@@ -494,6 +568,12 @@ async def run_agent(
rounds = 0
while True:
calls: list[ToolCallPiece] = []
# Phase 71: one fresh filter per round (one per model request —
# the retry attempts of this logical request share it: a restart
# only happens while the filter was never fed). Content-only:
# thinking pieces pass through raw.
round_filter = ScaffoldingFilter()
round_content = 0 # visible (clean) content chars this round
# Phase 48: bind the round's stream so a consumer abandon
# (GeneratorExit into the yield below) tears down the in-flight
# model stream deterministically — not GC-dependent. Phase 67:
@@ -511,16 +591,97 @@ async def run_agent(
tools=tools,
retries=settings.llm_retries,
delay=settings.llm_retry_delay,
scaffolding=round_filter,
)
try:
async for piece in stream:
if isinstance(piece, ToolCallPiece):
calls.append(piece)
elif isinstance(piece, StreamPiece) and piece.kind == "content":
round_content += len(piece.text)
yield piece
finally:
await stream.aclose()
# Phase 71: the per-strip-event capture log — one warning per
# stripped span, truncated to 200 chars (how a new scaffolding
# format gets captured and added to the registry) — and the turn
# total for the API layer's ``scaffold_stripped=N`` log field.
holder.scaffold_stripped += round_filter.stripped_chars
for span in round_filter.stripped_spans:
logger.warning(
"agent: stripped %d chars of tool-scaffolding in round %d: %r",
len(span),
rounds + 1,
span[:200],
)
if not calls:
return # the answer was streamed
if round_content > 0:
return # the answer was streamed (the clean content stands)
if round_filter.stripped_chars == 0:
# Empty/thinking-only answer — today's behavior, unchanged
# (the UI handles it); the guardrail keys on a strip.
return
# Phase 71: the scaffolding was the whole "answer" — the ONE
# bounded recovery (a fixed policy, not a conversation):
# ``tools=None``, the correction folded into the ORIGINAL
# single system message (provider-safe — the user message and
# any tool history stay in place), a fresh filter, the same
# phase-67 retry budget.
logger.warning(
"agent: round %d was pure tool-scaffolding (%d chars stripped) "
"— running the one bounded recovery",
rounds + 1,
round_filter.stripped_chars,
)
messages_recovered = [
{
"role": "system",
"content": system_prompt + "\n" + CORRECTION_INSTRUCTION,
},
*messages[1:],
]
recovery_filter = ScaffoldingFilter()
recovered = chat_stream_retried(
llm,
cast("list[dict[str, str]]", messages_recovered),
tools=None,
retries=settings.llm_retries,
delay=settings.llm_retry_delay,
scaffolding=recovery_filter,
)
recovery_content = 0
try:
async for piece in recovered:
if isinstance(piece, StreamPiece) and piece.kind == "content":
recovery_content += len(piece.text)
yield piece
finally:
await recovered.aclose()
holder.scaffold_stripped += recovery_filter.stripped_chars
for span in recovery_filter.stripped_spans:
logger.warning(
"agent: stripped %d chars of tool-scaffolding in the "
"recovery after round %d: %r",
len(span),
rounds + 1,
span[:200],
)
if recovery_content > 0:
return # the recovery answered — the turn ends
# The second empty reply is terminal (at most one recovery per
# turn). Raised OUTSIDE the stream, so chat_stream_retried's
# retry rule never sees it; the API layer catches it before
# the generic LLMError handler.
logger.warning(
"agent: the recovery reply was still empty "
"(scaffold_stripped=%d) — settling with a malformed-reply error",
holder.scaffold_stripped,
)
raise MalformedReplyError(
f"the model answered in raw tool-scaffolding twice in a row "
f"(round {rounds + 1} plus one recovery) — no clean answer "
"to stream"
)
call = calls[0] # a stream can carry several calls; run the first
result = _execute_tool(db, call, seed_docs, holder)
rounds += 1 # every call the model emits consumes a round
@@ -558,17 +719,46 @@ async def run_agent(
# teardown as the loop rounds (consumer abandon mid-final
# answer must still close the model's stream). Phase 67: the
# forced call retries under the same locked-A2 rule as the
# loop rounds.
# loop rounds. Phase 71: the forced final runs through a
# fresh filter too — raw scaffolding can never reach the
# user from ANY grounded request.
final_filter = ScaffoldingFilter()
final = chat_stream_retried(
llm,
cast("list[dict[str, str]]", messages),
tools=None,
retries=settings.llm_retries,
delay=settings.llm_retry_delay,
scaffolding=final_filter,
)
final_content = 0
try:
async for piece in final:
if isinstance(piece, StreamPiece) and piece.kind == "content":
final_content += len(piece.text)
yield piece
finally:
await final.aclose()
# Phase 71: the same capture log + turn total; a
# scaffolding-only forced final (this turn used no recovery,
# so nothing is doubled up) settles with the same terminal
# malformed-reply error rather than a silently empty answer.
holder.scaffold_stripped += final_filter.stripped_chars
for span in final_filter.stripped_spans:
logger.warning(
"agent: stripped %d chars of tool-scaffolding in the "
"forced final answer (round %d): %r",
len(span),
rounds + 1,
span[:200],
)
if final_content == 0 and final_filter.stripped_chars > 0:
logger.warning(
"agent: the forced final answer was pure tool-scaffolding "
"— settling with a malformed-reply error"
)
raise MalformedReplyError(
"the forced final answer was raw tool-scaffolding — no "
"clean answer to stream"
)
return
+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
+25 -5
View File
@@ -30,7 +30,19 @@ Agent tools (phase 37; phase 70: the copy teaches the harness-aligned
may extend its context through the three server-side tools (round-
capped, see :mod:`app.rag.agent`; the cap is the bound and this section
does not re-state it, phase 45). The LOW/deflection prompt never
carries it and stays byte-identical to the pre-phase text.
carries it (phase 71: the LOW prompt's only addition is the
plain-text line below — it still has no ``<tools>`` section).
Deflection plain-text line (phase 71, owner-permitted 2026-09-03):
the otherwise-locked ``LOW`` prompt gains exactly one instruction
line — "Reply in plain text only — you have no tools in this mode."
— appended to the ``DEFLECT_MODE`` body: a deflected turn offers no
tools, so any tool markup there is always wrong, and the line closes
the door at the prompt (the deterministic filter + one bounded
recovery in :mod:`app.rag.scaffolding` / :mod:`app.rag.agent` is the
backstop). The ``DEFLECT_MODE`` marker and everything else in the
prompt stay put — the E2E mock LLM keys on the marker's *presence*,
not the wording, so that contract is unchanged.
"""
from __future__ import annotations
@@ -83,9 +95,10 @@ _KB_INTRO = (
#: not re-state it, phase 45). Appended after the mode body
#: (``<documents>``), so the instructions are the last thing the model
#: reads. The LOW/deflection prompt never carries it — a deflection has
#: no grounded context to extend — and stays byte-identical to the
#: pre-phase text. The E2E mock keys off the ``<tools>`` marker's
#: *presence*, not this wording.
#: no grounded context to extend (phase 71: the LOW prompt's only
#: addition is the plain-text line in :func:`build_deflect_prompt`).
#: The E2E mock keys off the ``<tools>`` marker's *presence*, not this
#: wording.
TOOLS_SECTION: str = (
"<tools>\n"
"You may extend your context with three tools. `ls` lists the "
@@ -224,7 +237,10 @@ def build_deflect_prompt(
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
their section, keeping the prompt byte-identical to the pre-phase text.
their section, keeping the prompt byte-identical to the pre-phase
text. The body ends with the phase-71 plain-text line (owner-
permitted 2026-09-03 — the LOW prompt's only change): a deflected
turn offers no tools, so any tool markup there is always wrong.
"""
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
mid = "\n".join(
@@ -239,5 +255,9 @@ def build_deflect_prompt(
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend "
"they answer it. Use them to propose 2-3 alternative questions.\n"
# Phase 71 (owner-permitted 2026-09-03): the one plain-text line
# — prevention at the prompt. The E2E mock keys on the
# DEFLECT_MODE marker's presence, so appending is safe.
"Reply in plain text only — you have no tools in this mode.\n"
+ weak
)
+170
View File
@@ -0,0 +1,170 @@
"""Deterministic tool-scaffolding guardrail — pattern registry + streaming
filter (phase 71, task 01).
The ``lite`` chat model occasionally emits its own chat-template
tool-scaffolding as plain answer text (incident 2026-09-03: a deflected
round streamed ``<|tool_call_start|>[read(path='…')]<|tool_call_end|>``
into the UI although no tools were offered). This module is the
detection half of the guardrail: a fixed registry of *observed*
scaffolding forms and a streaming state machine that strips them from
``delta.content`` as it flows. Pure Python — no I/O, no logging, no
model: the strip *warning* log and the one bounded recovery are emitted
by the integration layer (phase 71, tasks 02–03), so this module stays
unit-testable in isolation.
Content only: thinking pieces are the model's raw reasoning by design
(phase 17) and are never filtered — the guardrail protects the answer,
not the scratchpad.
"""
import re
__all__ = ["SCAFFOLD_PATTERNS", "ScaffoldingFilter"]
#: The known scaffolding forms. The registry is the extension point: a
#: new entry needs an observed capture (the strip warning log, task 03)
#: + a unit fixture in ``tests/unit/test_scaffolding_filter.py`` — no
#: speculative entries. Every entry here traces to the 2026-09-03
#: incident (the span form is the deflected round's raw text; the
#: standalone siblings are from the same tokenizer family).
SCAFFOLD_PATTERNS: tuple[re.Pattern, ...] = (
# The observed span (incident 2026-09-03) — non-greedy, so multiple
# spans in one buffer each strip to their own end token.
re.compile(r"<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>"),
# Standalone sibling token (same tokenizer family).
re.compile(r"<\|tool_calls\|>"),
# Standalone sibling token (same tokenizer family).
re.compile(r"<\|tool_call\|>"),
)
_SPAN_START = "<|tool_call_start|>"
_SPAN_END = "<|tool_call_end|>"
#: The literal openings a live (possibly incomplete) match can begin with.
_OPENINGS: tuple[str, ...] = (_SPAN_START, "<|tool_calls|>", "<|tool_call|>")
#: Boundedness bound: in NORMAL state the held tail is at most one char
#: short of the longest opening (an open span is unbounded, but it is
#: being *stripped*, never emitted).
_MAX_OPENING = max(len(opening) for opening in _OPENINGS)
def _leftmost_match(buf: str) -> re.Match | None:
"""The leftmost complete match among :data:`SCAFFOLD_PATTERNS`, or
None when the buffer holds no complete scaffolding form.
(Two patterns cannot match at the same start position — their
literals diverge after ``<|tool_call`` — so the leftmost start is
unambiguous.)
"""
best: re.Match | None = None
for pattern in SCAFFOLD_PATTERNS:
match = pattern.search(buf)
if match is not None and (best is None or match.start() < best.start()):
best = match
return best
def _hold_index(buf: str) -> int:
"""Index where the live hold-tail begins; everything from it stays
pending (called only after the strip loop found no complete match).
The tail is the leftmost of two candidates: an **open span** (a
start token with no end token after it — the span may continue in
future chunks, so everything from that start token onward is held)
or a **live prefix** (the longest suffix that is a proper prefix of
any opening literal — the token may complete in future chunks).
Everything before the leftmost candidate is safe to emit: no
complete match remains, and no match can grow from earlier text,
since a complete opening earlier in the buffer would already have
matched (span) or be a candidate in its own right (standalone).
"""
if not buf:
return 0
hold = len(buf)
start = buf.find(_SPAN_START)
if start != -1 and buf.find(_SPAN_END, start + 1) == -1:
hold = start
for k in range(min(len(buf), _MAX_OPENING - 1), 0, -1):
suffix = buf[-k:]
if any(k < len(opening) and opening.startswith(suffix) for opening in _OPENINGS):
hold = min(hold, len(buf) - k)
break
return hold
class ScaffoldingFilter:
"""Streaming state machine over a model **content** stream (phase 71).
One 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). Feed the raw ``delta.content`` chunks; each
:meth:`feed` returns the clean text safe to emit *now*; :meth:`flush`
emits the tail at stream end.
``feed`` appends the chunk to the internal pending buffer, then
(a) repeatedly takes the leftmost complete match among
:data:`SCAFFOLD_PATTERNS` — drops it (counting it into
:attr:`stripped_chars`) and continues — until none remains; then
(b) checks the buffer tail for a **live prefix** via
:func:`_hold_index`: the longest suffix that is a proper prefix of
any opening literal, or an **open span** (a start token with no end
token yet — everything from that start token onward is held, since
the span may continue in future chunks). Everything before the held
tail is emitted; the held tail becomes 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`` (end of stream) emits the pending tail **as-is**: a
partial marker at EOF is content, not scaffolding — a documented,
pinned choice (a stream that ends mid-``<|tool_call_st`` must not
be silently eaten, and a lone ``<|tool_call_end|>`` without a start
is prose the user sent or the model produced outside a span).
The stripped spans themselves are exposed read-only
(:attr:`stripped_spans`) so the integration layer can log one
warning per strip event (phase 71 task 03) — that log line is how
a *new* scaffolding format gets captured and added to the registry.
"""
def __init__(self) -> None:
self._pending = ""
self._stripped_chars = 0
self._stripped_spans: list[str] = []
@property
def stripped_chars(self) -> int:
"""Total characters removed so far (read by the caller after the
round/turn — the strip warning log, phase 71 task 03)."""
return self._stripped_chars
@property
def stripped_spans(self) -> list[str]:
"""The raw spans removed so far, in stream order (one entry per
strip event — the integration layer's per-span warning log
truncates each to 200 chars). Read-only: callers must not
mutate the filter's state."""
return list(self._stripped_spans)
def feed(self, chunk: str) -> str:
"""Append *chunk* to the pending buffer; return the clean text
safe to emit now (possibly ``""`` — e.g. while an open span or a
partial marker is still held). An empty chunk is a no-op."""
if not chunk:
return ""
buf = self._strip_complete(self._pending + chunk)
hold = _hold_index(buf)
self._pending = buf[hold:]
return buf[:hold]
def flush(self) -> str:
"""End of stream: emit the pending tail as-is and reset it."""
tail = self._pending
self._pending = ""
return tail
def _strip_complete(self, buf: str) -> str:
"""Drop leftmost-complete matches until none remains (step a)."""
while (match := _leftmost_match(buf)) is not None:
self._stripped_chars += match.end() - match.start()
self._stripped_spans.append(match.group(0))
buf = buf[: match.start()] + buf[match.end():]
return buf