Follow-up to the salvaged #71756: instead of webhook importing cron's private _is_cron_silence_response, the loose autonomous-lane matcher now lives in gateway/response_filters.py as is_autonomous_silence_response, sharing LIVE_GATEWAY_SILENT_MARKERS with the interactive exact-marker rule so the marker sets can never drift. Cron and webhook both delegate to it. Interactive gateway behavior unchanged.
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
"""Gateway response filtering helpers.
|
|
|
|
These helpers operate at the gateway boundary: they decide whether a completed
|
|
agent turn should be delivered to the chat, not what should be persisted in the
|
|
conversation history.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unicodedata
|
|
from typing import Any
|
|
|
|
# Canonical model-emitted control token for intentional silence.
|
|
SILENT_REPLY_TOKEN = "NO_REPLY"
|
|
|
|
# Exact whole-response markers that mean "the agent intentionally chose not to
|
|
# reply". Keep this list small and explicit; arbitrary empty output remains an
|
|
# error/empty-response path, not silence.
|
|
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
|
|
"[SILENT]",
|
|
"SILENT",
|
|
"NO_REPLY",
|
|
"NO REPLY",
|
|
})
|
|
|
|
|
|
def _canonical_silence_candidate(text: str) -> str:
|
|
return " ".join(text.strip().upper().split())
|
|
|
|
|
|
def _strip_edge_silence_punctuation(text: str) -> str:
|
|
"""Strip stray edge punctuation without erasing marker structure.
|
|
|
|
Models sometimes emit ``.NO_REPLY`` or ``*NO_REPLY*`` instead of the exact
|
|
marker. Keep square brackets structural so malformed ``[SILENT`` does not
|
|
become ``SILENT``.
|
|
"""
|
|
start = 0
|
|
end = len(text)
|
|
while start < end and text[start] not in "[]" and unicodedata.category(text[start]).startswith("P"):
|
|
start += 1
|
|
while end > start and text[end - 1] not in "[]" and unicodedata.category(text[end - 1]).startswith("P"):
|
|
end -= 1
|
|
return text[start:end].strip()
|
|
|
|
|
|
def _canonical_silence_candidates(text: str) -> tuple[str, ...]:
|
|
exact = _canonical_silence_candidate(text)
|
|
stripped = _strip_edge_silence_punctuation(text.strip())
|
|
if stripped == text.strip():
|
|
return (exact,)
|
|
fallback = _canonical_silence_candidate(stripped)
|
|
return (exact, fallback)
|
|
|
|
|
|
def is_intentional_silence_response(response: Any) -> bool:
|
|
"""Return True only when ``response`` is exactly a silence marker.
|
|
|
|
Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be
|
|
delivered normally. A blank response is also not silence; blank output is
|
|
handled by the empty-response failure path.
|
|
"""
|
|
if not isinstance(response, str):
|
|
return False
|
|
stripped = response.strip()
|
|
if not stripped:
|
|
return False
|
|
if len(stripped) > 64:
|
|
return False
|
|
return any(candidate in LIVE_GATEWAY_SILENT_MARKERS for candidate in _canonical_silence_candidates(stripped))
|
|
|
|
|
|
def is_autonomous_silence_response(response: Any) -> bool:
|
|
"""Loose silence matcher for autonomous lanes (cron, webhook).
|
|
|
|
Autonomous lanes instruct the agent to emit ``[SILENT]`` when a tick
|
|
produced nothing worth a human's attention, and models reliably bracket
|
|
the marker with a short note explaining why they stayed quiet. Unlike
|
|
:func:`is_intentional_silence_response` (the interactive-chat rule, which
|
|
demands the response be EXACTLY a marker), this suppresses when a marker
|
|
is the whole response, sits on its own first or last line, or the
|
|
bracketed sentinel opens the response (the documented
|
|
``[SILENT] No changes detected`` pattern). A token buried mid-sentence
|
|
in a genuine report is still delivered.
|
|
|
|
Shares :data:`LIVE_GATEWAY_SILENT_MARKERS` so the interactive and
|
|
autonomous marker sets can never drift apart.
|
|
"""
|
|
if not isinstance(response, str):
|
|
return False
|
|
stripped = response.strip()
|
|
if not stripped:
|
|
return False
|
|
|
|
def _is_token(line: str) -> bool:
|
|
return _canonical_silence_candidate(line) in LIVE_GATEWAY_SILENT_MARKERS
|
|
|
|
# Whole response is exactly a token.
|
|
if _is_token(stripped):
|
|
return True
|
|
# Marker on its own first or last line (leading/trailing note on a
|
|
# separate line — e.g. "2 deals filtered\n\n[SILENT]").
|
|
lines = [ln for ln in stripped.splitlines() if ln.strip()]
|
|
if lines and (_is_token(lines[0]) or _is_token(lines[-1])):
|
|
return True
|
|
# Bracketed sentinel used as a same-line prefix — the documented pattern
|
|
# "[SILENT] No changes detected". Restricted to the bracketed form so a
|
|
# bare word like "Silent retry succeeded" is NOT swallowed.
|
|
if stripped.upper().startswith("[SILENT]"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:
|
|
"""Silence markers suppress delivery only for successful agent turns."""
|
|
if not isinstance(agent_result, dict):
|
|
return False
|
|
if agent_result.get("failed"):
|
|
return False
|
|
return is_intentional_silence_response(response)
|
|
|
|
|
|
def is_partial_silence_marker(text: Any) -> bool:
|
|
"""Return True while ``text`` could still resolve to a silence marker.
|
|
|
|
The streaming path accumulates the reply delta-by-delta and must decide,
|
|
before the whole response is known, whether to show what it has so far.
|
|
A buffer whose canonical form is a non-empty *prefix* of a silence marker
|
|
(e.g. ``"NO"`` on the way to ``"NO_REPLY"``, or an exact marker that has
|
|
not yet been terminated by stream-end) is held back so a raw marker is
|
|
never edited onto the screen and then belatedly retracted.
|
|
|
|
Anything that has already diverged from every marker (ordinary prose) —
|
|
and anything longer than the marker cap — returns False so normal
|
|
streaming resumes immediately. This is the streaming counterpart to
|
|
:func:`is_intentional_silence_response`, sharing the same marker set and
|
|
canonicalization so the two never drift.
|
|
"""
|
|
if not isinstance(text, str):
|
|
return False
|
|
stripped = text.strip()
|
|
if not stripped or len(stripped) > 64:
|
|
return False
|
|
for candidate in _canonical_silence_candidates(stripped):
|
|
if candidate and any(marker.startswith(candidate) for marker in LIVE_GATEWAY_SILENT_MARKERS):
|
|
return True
|
|
return False
|