mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
refactor(gateway): promote autonomous silence matcher to shared response_filters helper
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.
This commit is contained in:
parent
55d3272286
commit
136f8dab67
4 changed files with 84 additions and 44 deletions
|
|
@ -292,7 +292,9 @@ SILENT_MARKER = "[SILENT]"
|
|||
# a marker is the entire response OR appears as its own first/last line — but
|
||||
# NOT when a token merely appears mid-sentence in a genuine report (e.g.
|
||||
# "I considered staying [SILENT] but here is the summary…" must deliver).
|
||||
_CRON_SILENCE_TOKENS = frozenset({"[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"})
|
||||
# The actual matcher is shared with the webhook lane —
|
||||
# gateway.response_filters.is_autonomous_silence_response — so the two
|
||||
# autonomous lanes cannot drift apart.
|
||||
|
||||
|
||||
def _is_cron_silence_response(text: str) -> bool:
|
||||
|
|
@ -303,31 +305,13 @@ def _is_cron_silence_response(text: str) -> bool:
|
|||
variants the model emits when it drops the brackets (#51438, #46917).
|
||||
Whitespace-trimmed and case-insensitive. A token buried mid-sentence is
|
||||
treated as real content and delivered.
|
||||
|
||||
Delegates to the shared autonomous-lane matcher in
|
||||
:mod:`gateway.response_filters` (also used by the webhook adapter).
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
from gateway.response_filters import is_autonomous_silence_response
|
||||
|
||||
def _is_token(line: str) -> bool:
|
||||
return " ".join(line.strip().upper().split()) in _CRON_SILENCE_TOKENS
|
||||
|
||||
# Whole response is exactly a token.
|
||||
if _is_token(stripped):
|
||||
return True
|
||||
# Marker on its own first or last line (trailing/leading 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 cron
|
||||
# pattern "[SILENT] No changes detected". Restricted to the bracketed
|
||||
# form so a bare word like "Silent retry succeeded" is NOT swallowed.
|
||||
upper = stripped.upper()
|
||||
if upper.startswith("[SILENT]"):
|
||||
return True
|
||||
return False
|
||||
return is_autonomous_silence_response(text)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent thread pool for parallel cron jobs.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ from gateway.platforms.webhook_filters import (
|
|||
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
|
||||
WebhookRouteProcessor,
|
||||
)
|
||||
from gateway.response_filters import is_autonomous_silence_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -76,29 +77,24 @@ def _is_webhook_silence_response(content: Any) -> bool:
|
|||
already replied, a routine close). Nobody is waiting on the other end, so
|
||||
there is no reader for whom a "nothing happened" message is useful.
|
||||
|
||||
The reason this is cron's looser rule rather than the live gateway's is what
|
||||
the two lanes optimise for. In an interactive chat, swallowing a real answer
|
||||
because it happens to open with a marker is much worse than showing a stray
|
||||
marker, so ``is_intentional_silence_response`` demands the response be
|
||||
EXACTLY a marker. A webhook run has the opposite payoff: the cost of a
|
||||
leaked non-story is a pointless notification on every tick, and models
|
||||
reliably add a sentence explaining why they stayed quiet — which under the
|
||||
strict rule flips the whole thing back to "deliver". That is not a
|
||||
hypothetical: it is why a Helper support lane kept messaging its owner to
|
||||
The reason this is the loose autonomous rule rather than the live gateway's
|
||||
is what the two lanes optimise for. In an interactive chat, swallowing a
|
||||
real answer because it happens to open with a marker is much worse than
|
||||
showing a stray marker, so ``is_intentional_silence_response`` demands the
|
||||
response be EXACTLY a marker. A webhook run has the opposite payoff: the
|
||||
cost of a leaked non-story is a pointless notification on every tick, and
|
||||
models reliably add a sentence explaining why they stayed quiet — which
|
||||
under the strict rule flips the whole thing back to "deliver". That is not
|
||||
a hypothetical: it is why a Helper support lane kept messaging its owner to
|
||||
report that it had nothing to report.
|
||||
|
||||
So reuse cron's matcher, which already treats a marker on its own first or
|
||||
last line as silence while still delivering prose that merely mentions one
|
||||
mid-sentence. Sharing the function keeps the two autonomous lanes from
|
||||
drifting apart, and keeps the interactive path untouched.
|
||||
So use the shared autonomous-lane matcher (also used by cron), which treats
|
||||
a marker on its own first or last line as silence while still delivering
|
||||
prose that merely mentions one mid-sentence. Sharing the function keeps
|
||||
the two autonomous lanes from drifting apart, and keeps the interactive
|
||||
path untouched.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
try:
|
||||
from cron.scheduler import _is_cron_silence_response
|
||||
except Exception: # pragma: no cover - cron package always ships with the gateway
|
||||
return False
|
||||
return _is_cron_silence_response(content)
|
||||
return is_autonomous_silence_response(content)
|
||||
|
||||
# Sentinel returned by _resolve_request_profile when a /p/<profile>/ prefix
|
||||
# names a profile this gateway does not serve (→ 404). Distinct from None
|
||||
|
|
|
|||
|
|
@ -70,6 +70,47 @@ def is_intentional_silence_response(response: Any) -> bool:
|
|||
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):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from gateway.response_filters import (
|
||||
is_autonomous_silence_response,
|
||||
is_intentional_silence_agent_result,
|
||||
is_intentional_silence_response,
|
||||
)
|
||||
|
|
@ -25,3 +26,21 @@ def test_blank_and_prose_mentions_are_not_silence():
|
|||
def test_failed_agent_result_never_counts_as_intentional_silence():
|
||||
assert is_intentional_silence_agent_result({"failed": False}, "NO_REPLY")
|
||||
assert not is_intentional_silence_agent_result({"failed": True}, "NO_REPLY")
|
||||
|
||||
|
||||
def test_autonomous_silence_accepts_marker_with_own_line_note():
|
||||
"""The loose rule for cron/webhook lanes: marker + explanation suppresses."""
|
||||
assert is_autonomous_silence_response("[SILENT]")
|
||||
assert is_autonomous_silence_response("[SILENT]\n\nNothing new this tick.")
|
||||
assert is_autonomous_silence_response("2 deals filtered\n\n[SILENT]")
|
||||
assert is_autonomous_silence_response("no_reply\nduplicate inbound, already handled")
|
||||
assert is_autonomous_silence_response("[SILENT] No changes detected")
|
||||
|
||||
|
||||
def test_autonomous_silence_still_delivers_mid_sentence_mentions():
|
||||
assert not is_autonomous_silence_response(
|
||||
"I considered staying [SILENT] but this one moved money, so: refunded $240."
|
||||
)
|
||||
assert not is_autonomous_silence_response("Silent retry succeeded; all good.")
|
||||
assert not is_autonomous_silence_response("")
|
||||
assert not is_autonomous_silence_response(None)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue