diff --git a/gateway/display_config.py b/gateway/display_config.py index eab6bebc7830b..268ef6b713582 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -41,6 +41,12 @@ # live, just cleaned up after success so the chat doesn't fill up with # stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs. "cleanup_progress": False, + # When true, suppresses retry/empty-response/thinking-only/fallback status + # bubbles and the "(empty)" sentinel substitution. Intended for deployments + # where agent personas can legitimately produce empty responses (e.g. "stay + # silent when addressed to someone else") that the generic retry logic would + # otherwise misinterpret as failures. Off by default. + "suppress_retry_status": False, } # --------------------------------------------------------------------------- @@ -194,7 +200,7 @@ def _normalise(setting: str, value: Any) -> Any: if isinstance(value, str): return value.lower() in {"true", "1", "yes", "on"} return bool(value) - if setting == "cleanup_progress": + if setting in {"cleanup_progress", "suppress_retry_status"}: if isinstance(value, str): return value.lower() in {"true", "1", "yes", "on"} return bool(value) diff --git a/gateway/run.py b/gateway/run.py index 6dfef600593ab..e67ce95eeec56 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1073,11 +1073,33 @@ def _format_gateway_process_notification(evt: dict) -> "str | None": _gateway_runner_ref: _weakref.ref = lambda: None +def _resolve_suppress_retry_status(platform_key: str) -> bool: + """Resolve the ``display.platforms..suppress_retry_status`` flag. + + Returns True when the active platform has ``suppress_retry_status`` set to a + truthy value in the display config. Used to silence the ⚠️ empty-response + substitution and the generic "no response was generated" warning for + deployments where empty model responses are a legitimate outcome (e.g. an + agent persona that stays silent when a message is addressed to another + participant). + """ + try: + from gateway.display_config import resolve_display_setting + from hermes_cli.config import load_config as _load_cfg + cfg = _load_cfg() or {} + return bool( + resolve_display_setting(cfg, platform_key, "suppress_retry_status", False) + ) + except Exception: + return False + + def _normalize_empty_agent_response( agent_result: dict, response: str, *, history_len: int = 0, + platform_key: str = "", ) -> str: """Normalize empty/None agent responses into user-facing messages. @@ -1111,6 +1133,8 @@ def _normalize_empty_agent_response( if agent_result.get("partial"): err = agent_result.get("error", "processing incomplete") return f"⚠️ Processing stopped: {str(err)[:200]}. Try again." + if _resolve_suppress_retry_status(platform_key): + return response return ( "⚠️ Processing completed but no response was generated. " "This may be a transient error — try sending your message again." @@ -7644,11 +7668,14 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # prefill, empty-retry, fallback). Sending the raw sentinel # looks like a bug; a short explanation is more helpful. if response == "(empty)": - response = ( - "⚠️ The model returned no response after processing tool " - "results. This can happen with some models — try again or " - "rephrase your question." - ) + if _resolve_suppress_retry_status(_platform_name): + response = "" + else: + response = ( + "⚠️ The model returned no response after processing tool " + "results. This can happen with some models — try again or " + "rephrase your question." + ) agent_messages = agent_result.get("messages", []) _response_time = time.time() - _msg_start_time _api_calls = agent_result.get("api_calls", 0) @@ -7681,6 +7708,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # the case where agent did work but returned no text. Fix for #18765. response = _normalize_empty_agent_response( agent_result, response, history_len=len(history), + platform_key=_platform_name, ) # If the agent's session_id changed during compression, update diff --git a/run_agent.py b/run_agent.py index b60f6c43ce693..ed809b56b24d7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2884,6 +2884,38 @@ def _should_emit_quiet_tool_messages(self) -> bool: and getattr(self, "platform", "") == "cli" ) + def _should_suppress_retry_status(self) -> bool: + """Return True when retry/empty-response status bubbles should be suppressed. + + Reads ``display.platforms..suppress_retry_status`` from config. + Cached per agent instance. + """ + cached = getattr(self, "_suppress_retry_status_cached", None) + if cached is not None: + return cached + try: + from gateway.display_config import resolve_display_setting + from hermes_cli.config import load_config as _load_cfg + cfg = _load_cfg() or {} + platform_key = (self.platform or "").lower().strip() or "cli" + result = bool( + resolve_display_setting(cfg, platform_key, "suppress_retry_status", False) + ) + except Exception: + result = False + self._suppress_retry_status_cached = result + return result + + def _emit_retry_status(self, message: str) -> None: + """Emit a retry/empty-response/fallback status bubble, if not suppressed. + + No-op when ``suppress_retry_status`` is enabled for the active platform. + Otherwise identical to ``_emit_status``. + """ + if self._should_suppress_retry_status(): + return + self._emit_status(message) + def _emit_status(self, message: str) -> None: """Emit a lifecycle status message to both CLI and gateway channels. @@ -15091,7 +15123,7 @@ def _stop_spinner(): "Empty response after tool calls — nudging model " "to continue processing" ) - self._emit_status( + self._emit_retry_status( "⚠️ Model returned empty after tool calls — " "nudging to continue" ) @@ -15137,7 +15169,7 @@ def _stop_spinner(): "prefilling to continue (%d/2)", self._thinking_prefill_retries, ) - self._emit_status( + self._emit_retry_status( f"↻ Thinking-only response — prefilling to continue " f"({self._thinking_prefill_retries}/2)" ) @@ -15173,7 +15205,7 @@ def _stop_spinner(): "retry %d/3 (model=%s)", self._empty_content_retries, self.model, ) - self._emit_status( + self._emit_retry_status( f"⚠️ Empty response from model — retrying " f"({self._empty_content_retries}/3)" ) @@ -15192,13 +15224,13 @@ def _stop_spinner(): self._empty_content_retries, self.model, self.provider, ) - self._emit_status( + self._emit_retry_status( "⚠️ Model returning empty responses — " "switching to fallback provider..." ) if self._try_activate_fallback(): self._empty_content_retries = 0 - self._emit_status( + self._emit_retry_status( f"↻ Switched to fallback: {self.model} " f"({self.provider})" ) @@ -15232,7 +15264,7 @@ def _stop_spinner(): "after exhausting retries and fallback. " "Reasoning: %s", reasoning_preview, ) - self._emit_status( + self._emit_retry_status( "⚠️ Model produced reasoning but no visible " "response after all retries. Returning empty." ) @@ -15244,7 +15276,7 @@ def _stop_spinner(): self._empty_content_retries, self.model, self.provider, ) - self._emit_status( + self._emit_retry_status( "❌ Model returned no content after all retries" + (" and fallback attempts." if self._fallback_chain else ". No fallback providers configured.")