From 136f8dab6709ac1a9caf8aade60446dd8cbab7a9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:22:42 -0700 Subject: [PATCH] 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. --- cron/scheduler.py | 32 +++++--------------- gateway/platforms/webhook.py | 36 ++++++++++------------ gateway/response_filters.py | 41 ++++++++++++++++++++++++++ tests/gateway/test_response_filters.py | 19 ++++++++++++ 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index a20260758997..91c3168268ce 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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. diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 14c0c221ba28..85ccedd13ca6 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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// prefix # names a profile this gateway does not serve (→ 404). Distinct from None diff --git a/gateway/response_filters.py b/gateway/response_filters.py index ccd2db370ffd..b11d585ea29c 100644 --- a/gateway/response_filters.py +++ b/gateway/response_filters.py @@ -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): diff --git a/tests/gateway/test_response_filters.py b/tests/gateway/test_response_filters.py index c24b9725cb75..a9f33662a053 100644 --- a/tests/gateway/test_response_filters.py +++ b/tests/gateway/test_response_filters.py @@ -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)