171 lines
7.5 KiB
Python
171 lines
7.5 KiB
Python
"""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
|