"""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