emma-hermes/gateway/response_filters.py

107 lines
3.9 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_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