{"repo":"NousResearch/hermes-agent","public_id":"24083","display_label":"Retry warning buffering and suppression options","summary":"These items relate to buffering or suppressing retry and fallback status messages in the agent, only surfacing them on terminal failure or when configured to do so. A config option to control this behavior is also proposed.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33816,"merged_at":"2026-05-28T11:53:27+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-28T11:53:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":24083,"pr_number":33816,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-28T11:53:27+00:00"}],"members":[{"kind":"issue","number":24083,"title":"Feature: Add config option to suppress API retry warnings in terminal","url":null,"state":"closed"},{"kind":"pr","number":24387,"title":"feat(agent): add retry warning suppression config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"67011cc0d76b7047320b2760e948b4e4488c24ca","number":0},{"kind":"pr","value":"#33816","number":33816},{"kind":"pr","value":"#24083","number":24083}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33816,"title":"feat(agent): buffer retry/fallback status, surface only on terminal failure","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/conversation_loop.py","agent/stream_diag.py","run_agent.py","tests/agent/test_codex_ttfb_watchdog.py","tests/run_agent/test_retry_status_buffer.py","tests/run_agent/test_stream_drop_logging.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -403,13 +403,13 @@ def _call():\n                 _elapsed, _ttfb_timeout, api_kwargs.get(\"model\", \"unknown\"),\n             )\n             if _silent_hint:\n-                agent._emit_status(\n+                agent._buffer_status(\n                     f\"⚠️ No first byte from provider in {int(_elapsed)}s \"\n                     f\"(codex stream, model: {api_kwargs.get('model', 'unknown')}). \"\n                     f\"Reconnecting. {_silent_hint}\"\n                 )\n             else:\n-                agent._emit_status(\n+                agent._buffer_status(\n                     f\"⚠️ No first byte from provider in {int(_elapsed)}s \"\n                     f\"(codex stream, model: {api_kwargs.get('model', 'unknown')}). \"\n                     f\"Reconnecting.\"","@@ -455,7 +455,7 @@ def _call():\n                 api_kwargs.get(\"model\", \"unknown\"),\n                 f\"{_est_tokens_for_codex_watchdog:,}\",\n             )\n-            agent._emit_status(\n+            agent._buffer_status(\n                 f\"⚠️ Codex stream sent no events for {int(_event_stale_elapsed)}s \"\n                 f\"after first byte (model: {api_kwargs.get('model', 'unknown')}). \"\n                 f\"Reconnecting.\"","@@ -493,13 +493,13 @@ def _call():\n                 api_kwargs.get(\"model\", \"unknown\"), f\"{_est_ctx:,}\",\n             )\n             if _silent_hint:\n-                agent._emit_status(\n+                agent._buffer_status(\n                     f\"⚠️ No response from provider for {int(_elapsed)}s \"\n                     f\"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). \"\n                     f\"{_silent_hint}\"\n                 )\n             else:\n-                agent._emit_status(\n+                agent._buffer_status(\n                     f\"⚠️ No response from provider for {int(_elapsed)}s \"\n                     f\"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). \"\n                     f\"Aborting call.\"","@@ -1262,7 +1262,7 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n                 api_mode=agent.api_mode,\n             )\n \n-        agent._emit_status(\n+        agent._buffer_status(\n             f\"🔄 Primary model failed — switching to fallback: \"\n             f\"{fb_model} via {fb_provider}\"\n         )","@@ -2251,7 +2251,7 @@ def _call():\n                             mid_tool_call=False,\n                             diag=request_client_holder.get(\"diag\"),\n                         )\n-                        agent._emit_status(\n+                        agent._buffer_status(\n                             \"❌ Provider returned malformed streaming data after \"\n                             f\"{_max_stream_retries + 1} attempts. \"\n                             \"The provider may be experiencing issues — \"","@@ -2358,7 +2358,7 @@ def _call():\n                 _stale_elapsed, _stream_stale_timeout,\n                 api_kwargs.get(\"model\", \"unknown\"), f\"{_est_ctx:,}\",\n             )\n-            agent._emit_status(\n+            agent._buffer_status(\n                 f\"⚠️ No response from provider for {int(_stale_elapsed)}s \"\n                 f\"(model: {api_kwargs.get('model', 'unknown')}, \"\n                 f\"context: ~{_est_ctx:,} tokens). \""]},{"file":"agent/conversation_loop.py","hunks":["@@ -1151,17 +1151,18 @@ def run_conversation(\n                             f\"Nous Portal rate limit active — \"\n                             f\"resets in {_fmt_nous_remaining(_nous_remaining)}.\"\n                         )\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}⏳ {_nous_msg} Trying fallback...\",\n-                            force=True,\n+                        agent._buffer_vprint(\n+                            f\"⏳ {_nous_msg} Trying fallback...\"\n                         )\n-                        agent._emit_status(f\"⏳ {_nous_msg}\")\n+                        agent._buffer_status(f\"⏳ {_nous_msg}\")\n                         if agent._try_activate_fallback():\n                             retry_count = 0\n                             compression_attempts = 0\n                             primary_recovery_attempted = False\n                             continue\n-                        # No fallback available — return with clear message\n+                        # No fallback available — surface buffered context\n+                        # so user sees the rate-limit message that led here.\n+                        agent._flush_status_buffer()\n                         agent._persist_session(messages, conversation_history)\n                         return {\n                             \"final_response\": (","@@ -1384,9 +1385,10 @@ def _stop_spinner():\n                             error_details.append(\"response.choices is empty\")\n \n                 if response_invalid:\n-                    # Stop spinner before printing error messages\n+                    # Stop spinner silently — retry status is now buffered\n+                    # and only surfaced if every retry+fallback exhausts.\n                     if thinking_spinner:\n-                        thinking_spinner.stop(\"(´;ω;`) oops, retrying...\")\n+                        thinking_spinner.stop(\"\")\n                         thinking_spinner = None\n                     if agent.thinking_callback:\n                         agent.thinking_callback(\"\")","@@ -1399,7 +1401,7 @@ def _stop_spinner():\n                     # rate-limit symptom.  Switch to fallback immediately\n                     # rather than retrying with extended backoff.\n                     if agent._fallback_index < len(agent._fallback_chain):\n-                        agent._emit_status(\"⚠️ Empty/malformed response — switching to fallback...\")\n+                        agent._buffer_status(\"⚠️ Empty/malformed response — switching to fallback...\")\n                     if agent._try_activate_fallback():\n                         retry_count = 0\n                         compression_attempts = 0","@@ -1461,20 +1463,22 @@ def _stop_spinner():\n                     else:\n                         _failure_hint = f\"response time {api_duration:.1f}s\"\n \n-                    agent._vprint(f\"{agent.log_prefix}⚠️  Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}\", force=True)\n-                    agent._vprint(f\"{agent.log_prefix}   🏢 Provider: {provider_name}\", force=True)\n+                    agent._buffer_vprint(f\"⚠️  Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}\")\n+                    agent._buffer_vprint(f\"   🏢 Provider: {provider_name}\")\n                     cleaned_provider_error = agent._clean_error_message(error_msg)\n-                    agent._vprint(f\"{agent.log_prefix}   📝 Provider message: {cleaned_provider_error}\", force=True)\n-                    agent._vprint(f\"{agent.log_prefix}   ⏱️  {_failure_hint}\", force=True)\n+                    agent._buffer_vprint(f\"   📝 Provider message: {cleaned_provider_error}\")\n+                    agent._buffer_vprint(f\"   ⏱️  {_failure_hint}\")\n                     \n                     if retry_count >= max_retries:\n                         # Try fallback before giving up\n-                        agent._emit_status(f\"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...\")\n+                        agent._buffer_status(f\"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...\")\n                         if agent._try_activate_fallback():\n                             retry_count = 0\n                             compression_attempts = 0\n                             primary_recovery_attempted = False\n                             continue\n+                        # Terminal — flush buffered retry trace so user sees what happened.\n+                        agent._flush_status_buffer()\n                         agent._emit_status(f\"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.\")\n                         logger.error(f\"{agent.log_prefix}Invalid API response after {max_retries} retries.\")\n                         agent._persist_session(messages, conversation_history)","@@ -1488,7 +1492,7 @@ def _stop_spinner():\n                     \n                     # Backoff before retry — jittered exponential: 5s base, 120s cap\n                     wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)\n-                    agent._vprint(f\"{agent.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...\", force=True)\n+                    agent._buffer_vprint(f\"⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...\")\n                     logger.warning(f\"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}\")\n                     \n                     # Sleep in small increments to stay responsive to interrupts","@@ -1715,14 +1719,14 @@ def _stop_spinner():\n                         if assistant_message is not None and _trunc_has_tool_calls:\n                             if truncated_tool_call_retries < 1:\n                                 truncated_tool_call_retries += 1\n-                                agent._vprint(\n-                                    f\"{agent.log_prefix}⚠️  Truncated tool call detected — retrying API call...\",\n-                                    force=True,\n+                                agent._buffer_vprint(\n+                                    f\"⚠️  Truncated tool call detected — retrying API call...\"\n                                 )\n                                 # Don't append the broken response to messages;\n                                 # just re-run the same API call from the current\n                                 # message state, giving the model another chance.\n                                 continue\n+                            agent._flush_status_buffer()\n                             agent._vprint(\n                                 f\"{agent.log_prefix}⚠️  Truncated tool call response detected again — refusing to execute incomplete tool arguments.\",\n                                 force=True,","@@ -1756,6 +1760,7 @@ def _stop_spinner():\n                         }\n                     else:\n                         # First message was truncated - mark as failed\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ First response truncated - cannot recover\", force=True)\n                         agent._persist_session(messages, conversation_history)\n                         return {","@@ -1907,6 +1912,11 @@ def _stop_spinner():\n                         )\n                 \n                 has_retried_429 = False  # Reset on success\n+                # Note: don't clear the retry buffer here — an \"API call\n+                # success\" only means we got bytes back, not that we got\n+                # usable content. Empty responses still loop through the\n+                # empty-retry path below; the buffer is cleared when\n+                # genuinely successful content is detected later (~L4127).\n                 # Clear Nous rate limit state on successful request —\n                 # proves the limit has reset and other sessions can\n                 # resume hitting Nous.","@@ -1933,9 +1943,10 @@ def _stop_spinner():\n                 break\n \n             except Exception as api_error:\n-                # Stop spinner before printing error messages\n+                # Stop spinner silently — retry status is buffered and\n+                # only flushed when every retry+fallback is exhausted.\n                 if thinking_spinner:\n-                    thinking_spinner.stop(\"(╥_╥) error, retrying...\")\n+                    thinking_spinner.stop(\"\")\n                     thinking_spinner = None\n                 if agent.thinking_callback:\n                     agent.thinking_callback(\"\")","@@ -1990,14 +2001,12 @@ def _stop_spinner():\n                     if _surrogates_found or _is_surrogate_error:\n                         agent._unicode_sanitization_passes += 1\n                         if _surrogates_found:\n-                            agent._vprint(\n-                                f\"{agent.log_prefix}⚠️  Stripped invalid surrogate characters from messages. Retrying...\",\n-                                force=True,\n+                            agent._buffer_vprint(\n+                                f\"⚠️  Stripped invalid surrogate characters from messages. Retrying...\"\n                             )\n                         else:\n-                            agent._vprint(\n-                                f\"{agent.log_prefix}⚠️  Surrogate encoding error — retrying after full-payload sanitization...\",\n-                                force=True,\n+                            agent._buffer_vprint(\n+                                f\"⚠️  Surrogate encoding error — retrying after full-payload sanitization...\"\n                             )\n                         continue\n                     if _is_ascii_codec:","@@ -2325,7 +2334,7 @@ def _stop_spinner():\n                     codex_auth_retry_attempted = True\n                     if agent._try_refresh_codex_client_credentials(force=True):\n                         _label = \"xAI OAuth\" if agent.provider == \"xai-oauth\" else \"Codex\"\n-                        agent._vprint(f\"{agent.log_prefix}🔐 {_label} auth refreshed after 401. Retrying request...\")\n+                        agent._buffer_vprint(f\"🔐 {_label} auth refreshed after 401. Retrying request...\")\n                         continue\n                 if (\n                     agent.api_mode == \"chat_completions\"","@@ -2366,7 +2375,7 @@ def _stop_spinner():\n                 ):\n                     copilot_auth_retry_attempted = True\n                     if agent._try_refresh_copilot_client_credentials():\n-                        agent._vprint(f\"{agent.log_prefix}🔐 Copilot credentials refreshed after 401. Retrying request...\")\n+                        agent._buffer_vprint(f\"🔐 Copilot credentials refreshed after 401. Retrying request...\")\n                         continue\n                 if (\n                     agent.api_mode == \"anthropic_messages\"","@@ -2541,41 +2550,37 @@ def _stop_spinner():\n                 _base = getattr(agent, \"base_url\", \"unknown\")\n                 _model = getattr(agent, \"model\", \"unknown\")\n                 _status_code_str = f\" [HTTP {status_code}]\" if status_code else \"\"\n-                agent._vprint(f\"{agent.log_prefix}⚠️  API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}\", force=True)\n-                agent._vprint(f\"{agent.log_prefix}   🔌 Provider: {_provider}  Model: {_model}\", force=True)\n-                agent._vprint(f\"{agent.log_prefix}   🌐 Endpoint: {_base}\", force=True)\n-                agent._vprint(f\"{agent.log_prefix}   📝 Error: {_error_summary}\", force=True)\n+                agent._buffer_vprint(f\"⚠️  API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}\")\n+                agent._buffer_vprint(f\"   🔌 Provider: {_provider}  Model: {_model}\")\n+                agent._buffer_vprint(f\"   🌐 Endpoint: {_base}\")\n+                agent._buffer_vprint(f\"   📝 Error: {_error_summary}\")\n                 if status_code and status_code < 500:\n                     _err_body = getattr(api_error, \"body\", None)\n                     _err_body_str = str(_err_body)[:300] if _err_body else None\n                     if _err_body_str:\n-                        agent._vprint(f\"{agent.log_prefix}   📋 Details: {_err_body_str}\", force=True)\n-                agent._vprint(f\"{agent.log_prefix}   ⏱️  Elapsed: {elapsed_time:.2f}s  Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens\")\n+                        agent._buffer_vprint(f\"   📋 Details: {_err_body_str}\")\n+                agent._buffer_vprint(f\"   ⏱️  Elapsed: {elapsed_time:.2f}s  Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens\")\n \n                 # Actionable hint for OpenRouter \"no tool endpoints\" error.\n-                # This fires regardless of whether fallback succeeds — the\n-                # user needs to know WHY their model failed so they can fix\n-                # their provider routing, not just silently fall back.\n+                # Buffered like the rest of the retry trace — surfaced only\n+                # if every retry+fallback exhausts.  Avoids spamming users\n+                # who recover automatically via fallback.\n                 if (\n                     agent._is_openrouter_url()\n                     and \"support tool use\" in error_msg\n                 ):\n-                    agent._vprint(\n-                        f\"{agent.log_prefix}   💡 No OpenRouter providers for {_model} support tool calling with your current settings.\",\n-                        force=True,\n+                    agent._buffer_vprint(\n+                        f\"   💡 No OpenRouter providers for {_model} support tool calling with your current settings.\"\n                     )\n                     if agent.providers_allowed:\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}      Your provider_routing.only restriction is filtering out tool-capable providers.\",\n-                            force=True,\n+                        agent._buffer_vprint(\n+                            f\"      Your provider_routing.only restriction is filtering out tool-capable providers.\"\n                         )\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}      Try removing the restriction or adding providers that support tools for this model.\",\n-                            force=True,\n+                        agent._buffer_vprint(\n+                            f\"      Try removing the restriction or adding providers that support tools for this model.\"\n                         )\n-                    agent._vprint(\n-                        f\"{agent.log_prefix}      Check which providers support tools: https://openrouter.ai/models/{_model}\",\n-                        force=True,\n+                    agent._buffer_vprint(\n+                        f\"      Check which providers support tools: https://openrouter.ai/models/{_model}\"\n                     )\n \n                 # Check for interrupt before deciding to retry","@@ -2625,11 +2630,10 @@ def _stop_spinner():\n                             # user later enables extra usage the 1M limit\n                             # should come back automatically.\n                             compressor._context_probe_persistable = False\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}⚠️  Anthropic long-context tier \"\n+                        agent._buffer_vprint(\n+                            f\"⚠️  Anthropic long-context tier \"\n                             f\"requires extra usage — reducing context: \"\n-                            f\"{old_ctx:,} → {_reduced_ctx:,} tokens\",\n-                            force=True,\n+                            f\"{old_ctx:,} → {_reduced_ctx:,} tokens\"\n                         )\n \n                     compression_attempts += 1","@@ -2645,7 +2649,7 @@ def _stop_spinner():\n                         # messages to the new session, not skipping them.\n                         conversation_history = None\n                         if len(messages) < original_len or old_ctx > _reduced_ctx:\n-                            agent._emit_status(\n+                            agent._buffer_status(\n                                 f\"🗜️ Context reduced to {_reduced_ctx:,} tokens \"\n                                 f\"(was {old_ctx:,}), retrying...\"\n                             )","@@ -2675,11 +2679,11 @@ def _stop_spinner():\n                     )\n                     if not pool_may_recover:\n                         if classified.reason == FailoverReason.billing:\n-                            agent._emit_status(\n+                            agent._buffer_status(\n                                 \"⚠️ Billing or credits exhausted — switching to fallback provider...\"\n                             )\n                         else:\n-                            agent._emit_status(\"⚠️ Rate limited — switching to fallback provider...\")\n+                            agent._buffer_status(\"⚠️ Rate limited — switching to fallback provider...\")\n                         if agent._try_activate_fallback(reason=classified.reason):\n                             retry_count = 0\n                             compression_attempts = 0","@@ -2791,6 +2795,8 @@ def _stop_spinner():\n                 if is_payload_too_large:\n                     compression_attempts += 1\n                     if compression_attempts > max_compression_attempts:\n+                        # Terminal — surface the buffered retry trace.\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached for payload-too-large error.\", force=True)\n                         agent._vprint(f\"{agent.log_prefix}   💡 Try /new to start a fresh conversation, or /compress to retry compression.\", force=True)\n                         logger.error(f\"{agent.log_prefix}413 compression failed after {max_compression_attempts} attempts.\")","@@ -2804,7 +2810,7 @@ def _stop_spinner():\n                             \"failed\": True,\n                             \"compression_exhausted\": True,\n                         }\n-                    agent._emit_status(f\"⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n+                    agent._buffer_status(f\"⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n \n                     original_len = len(messages)\n                     messages, active_system_prompt = agent._compress_context(","@@ -2817,11 +2823,14 @@ def _stop_spinner():\n                     conversation_history = None\n \n                     if len(messages) < original_len:\n-                        agent._emit_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                        agent._buffer_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n                         time.sleep(2)  # Brief pause between compression retries\n                         restart_with_compressed_messages = True\n                         break\n                     else:\n+                        # Terminal — surface buffered context so the user\n+                        # sees what compression attempts were made.\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Payload too large and cannot compress further.\", force=True)\n                         agent._vprint(f\"{agent.log_prefix}   💡 Try /new to start a fresh conversation, or /compress to retry compression.\", force=True)\n                         logger.error(f\"{agent.log_prefix}413 payload too large. Cannot compress further.\")","@@ -2865,16 +2874,16 @@ def _stop_spinner():\n                         # touching context_length or triggering compression.\n                         safe_out = max(1, available_out - 64)  # small safety margin\n                         agent._ephemeral_max_output_tokens = [redacted:secret-assignment]\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}⚠️  Output cap too large for current prompt — \"\n+                        agent._buffer_vprint(\n+                            f\"⚠️  Output cap too large for current prompt — \"\n                             f\"retrying with max_tokens={safe_out:,} \"\n-                            f\"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})\",\n-                            force=True,\n+                            f\"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})\"\n                         )\n                         # Still count against compression_attempts so we don't\n                         # loop forever if the error keeps recurring.\n                         compression_attempts += 1\n                         if compression_attempts > max_compression_attempts:\n+                            agent._flush_status_buffer()\n                             agent._vprint(f\"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.\", force=True)\n                             agent._vprint(f\"{agent.log_prefix}   💡 Try /new to start a fresh conversation, or /compress to retry compression.\", force=True)\n                             logger.error(f\"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.\")","@@ -2910,13 +2919,12 @@ def _stop_spinner():\n                     )\n                     if parsed_limit and parsed_limit < old_ctx:\n                         new_ctx = parsed_limit\n-                        agent._vprint(f\"{agent.log_prefix}Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})\", force=True)\n+                        agent._buffer_vprint(f\"Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})\")\n                     elif minimax_delta_only_overflow:\n                         new_ctx = old_ctx\n-                        agent._vprint(\n-                            f\"{agent.log_prefix}Provider reported overflow amount only; \"\n-                            f\"keeping context_length at {old_ctx:,} tokens and compressing.\",\n-                            force=True,\n+                        agent._buffer_vprint(\n+                            f\"Provider reported overflow amount only; \"\n+                            f\"keeping context_length at {old_ctx:,} tokens and compressing.\"\n                         )\n                     else:\n                         # Step down to the next probe tier","@@ -2943,12 +2951,13 @@ def _stop_spinner():\n                             compressor._context_probe_persistable = bool(\n                                 parsed_limit and parsed_limit == new_ctx\n                             )\n-                        agent._vprint(f\"{agent.log_prefix}⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens\", force=True)\n+                        agent._buffer_vprint(f\"⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens\")\n                     else:\n-                        agent._vprint(f\"{agent.log_prefix}⚠️  Context length exceeded at minimum tier — attempting compression...\", force=True)\n+                        agent._buffer_vprint(f\"⚠️  Context length exceeded at minimum tier — attempting compression...\")\n \n                     compression_attempts += 1\n                     if compression_attempts > max_compression_attempts:\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.\", force=True)\n                         agent._vprint(f\"{agent.log_prefix}   💡 Try /new to start a fresh conversation, or /compress to retry compression.\", force=True)\n                         logger.error(f\"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.\")","@@ -2962,7 +2971,7 @@ def _stop_spinner():\n                             \"failed\": True,\n                             \"compression_exhausted\": True,\n                         }\n-                    agent._emit_status(f\"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...\")\n+                    agent._buffer_status(f\"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...\")\n \n                     original_len = len(messages)\n                     messages, active_system_prompt = agent._compress_context(","@@ -2976,12 +2985,13 @@ def _stop_spinner():\n \n                     if len(messages) < original_len or new_ctx and new_ctx < old_ctx:\n                         if len(messages) < original_len:\n-                            agent._emit_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                            agent._buffer_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n                         time.sleep(2)  # Brief pause between compression retries\n                         restart_with_compressed_messages = True\n                         break\n                     else:\n                         # Can't compress further and already at minimum tier\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Context length exceeded and cannot compress further.\", force=True)\n                         agent._vprint(f\"{agent.log_prefix}   💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.\", force=True)\n                         logger.error(f\"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.\")","@@ -3070,7 +3080,7 @@ def _stop_spinner():\n                 if is_client_error:\n                     # Try fallback before aborting — a different provider\n                     # may not have the same issue (rate limit, auth, etc.)\n-                    agent._emit_status(f\"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...\")\n+                    agent._buffer_status(f\"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...\")\n                     if agent._try_activate_fallback():\n                         retry_count = 0\n                         compression_attempts = 0","@@ -3080,6 +3090,9 @@ def _stop_spinner():\n                         agent._dump_api_request_debug(\n                             api_kwargs, reason=\"non_retryable_client_error\", error=api_error,\n                         )\n+                    # Terminal — flush buffered context so the user sees\n+                    # what was tried before the abort.\n+                    agent._flush_status_buffer()\n                     agent._emit_status(\n                         f\"❌ Non-retryable error (HTTP {status_code}): \"\n                         f\"{agent._summarize_api_error(api_error)}\"","@@ -3165,12 +3178,14 @@ def _stop_spinner():\n                         retry_count = 0\n                         continue\n                     # Try fallback before giving up entirely\n-                    agent._emit_status(f\"⚠️ Max retries ({max_retries}) exhausted — trying fallback...\")\n+                    agent._buffer_status(f\"⚠️ Max retries ({max_retries}) exhausted — trying fallback...\")\n                     if agent._try_activate_fallback():\n                         retry_count = 0\n                         compression_attempts = 0\n                         primary_recovery_attempted = False\n                         continue\n+                    # Terminal — flush buffered retry/fallback trace.\n+                    agent._flush_status_buffer()\n                     _final_summary = agent._summarize_api_error(api_error)\n                     _billing_guidance = \"\"\n                     if classified.reason == FailoverReason.billing:","@@ -3270,9 +3285,9 @@ def _stop_spinner():\n                                 pass\n                 wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)\n                 if is_rate_limited:\n-                    agent._emit_status(f\"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...\")\n+                    agent._buffer_status(f\"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...\")\n                 else:\n-                    agent._emit_status(f\"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...\")\n+                    agent._buffer_status(f\"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...\")\n                 logger.warning(\n                     \"Retrying API call in %ss (attempt %s/%s) %s error=%s\",\n                     wait_time,","@@ -3431,14 +3446,15 @@ def _stop_spinner():\n             if has_incomplete_scratchpad(assistant_message.content or \"\"):\n                 agent._incomplete_scratchpad_retries += 1\n                 \n-                agent._vprint(f\"{agent.log_prefix}⚠️  Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)\")\n+                agent._buffer_vprint(f\"⚠️  Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)\")\n                 \n                 if agent._incomplete_scratchpad_retries <= 2:\n-                    agent._vprint(f\"{agent.log_prefix}🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...\")\n+                    agent._buffer_vprint(f\"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...\")\n                     # Don't add the broken message, just retry\n                     continue\n                 else:\n                     # Max retries - discard this turn and save as partial\n+                    agent._flush_status_buffer()\n                     agent._vprint(f\"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.\", force=True)\n                     agent._incomplete_scratchpad_retries = 0\n                     ","@@ -3546,9 +3562,10 @@ def _stop_spinner():\n                     available = \", \".join(sorted(agent.valid_tool_names))\n                     invalid_name = invalid_tool_calls[0]\n                     invalid_preview = invalid_name[:80] + \"...\" if len(invalid_name) > 80 else invalid_name\n-                    agent._vprint(f\"{agent.log_prefix}⚠️  Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)\")\n+                    agent._buffer_vprint(f\"⚠️  Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)\")\n \n                     if agent._invalid_tool_retries >= 3:\n+                        agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.\", force=True)\n                         agent._invalid_tool_retries = 0\n                         agent._persist_session(messages, conversation_history)","@@ -3632,16 +3649,16 @@ def _stop_spinner():\n                     agent._invalid_json_retries += 1\n \n                     tool_name, error_msg = invalid_json_args[0]\n-                    agent._vprint(f\"{agent.log_prefix}⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}\")\n+                    agent._buffer_vprint(f\"⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}\")\n \n                     if agent._invalid_json_retries < 3:\n-                        agent._vprint(f\"{agent.log_prefix}🔄 Retrying API call ({agent._invalid_json_retries}/3)...\")\n+                        agent._buffer_vprint(f\"🔄 Retrying API call ({agent._invalid_json_retries}/3)...\")\n                         # Don't add anything to messages, just retry the API call\n                         continue\n                     else:\n                         # Instead of returning partial, inject tool error results so the model can recover.\n                         # Using tool results (not user messages) preserves role alternation.\n-                        agent._vprint(f\"{agent.log_prefix}⚠️  Injecting recovery tool results for invalid JSON...\")\n+                        agent._buffer_vprint(f\"⚠️  Injecting recovery tool results for invalid JSON...\")\n                         agent._invalid_json_retries = 0  # Reset for next attempt\n                         \n                         # Append the assistant message with its (broken) tool_calls","@@ -3949,7 +3966,7 @@ def _stop_spinner():\n                             \"Empty response after tool calls — nudging model \"\n                             \"to continue processing\"\n                         )\n-                        agent._emit_status(\n+                        agent._buffer_status(\n                             \"⚠️ Model returned empty after tool calls — \"\n                             \"nudging to continue\"\n                         )","@@ -3995,7 +4012,7 @@ def _stop_spinner():\n                             \"prefilling to continue (%d/2)\",\n                             agent._thinking_prefill_retries,\n                         )\n-                        agent._emit_status(\n+                        agent._buffer_status(\n                             f\"↻ Thinking-only response — prefilling to continue \"\n                             f\"({agent._thinking_prefill_retries}/2)\"\n                         )","@@ -4030,7 +4047,7 @@ def _stop_spinner():\n                             \"retry %d/3 (model=%s)\",\n                             agent._empty_content_retries, agent.model,\n                         )\n-                        agent._emit_status(\n+                        agent._buffer_status(\n                             f\"⚠️ Empty response from model — retrying \"\n                             f\"({agent._empty_content_retries}/3)\"\n                         )","@@ -4049,13 +4066,13 @@ def _stop_spinner():\n                             agent._empty_content_retries, agent.model,\n                             agent.provider,\n                         )\n-                        agent._emit_status(\n+                        agent._buffer_status(\n                             \"⚠️ Model returning empty responses — \"\n                             \"switching to fallback provider...\"\n                         )\n                         if agent._try_activate_fallback():\n                             agent._empty_content_retries = 0\n-                            agent._emit_status(\n+                            agent._buffer_status(\n                                 f\"↻ Switched to fallback: {agent.model} \"\n                                 f\"({agent.provider})\"\n                             )","@@ -4069,6 +4086,9 @@ def _stop_spinner():\n                     # Exhausted retries and fallback chain (or no\n                     # fallback configured).  Fall through to the\n                     # \"(empty)\" terminal.\n+                    # Surface the buffered retry/fallback trace so the\n+                    # user can see what was attempted before \"(empty)\".\n+                    agent._flush_status_buffer()\n                     _turn_exit_reason = \"empty_response_exhausted\"\n                     reasoning_text = agent._extract_reasoning(assistant_message)\n                     agent._drop_trailing_empty_response_scaffolding(messages)","@@ -4113,6 +4133,9 @@ def _stop_spinner():\n                 # Reset retry counter/signature on successful content\n                 agent._empty_content_retries = 0\n                 agent._thinking_prefill_retries = 0\n+                # Successful content reached — drop any buffered retry\n+                # status from earlier failed attempts in this turn.\n+                agent._clear_status_buffer()\n \n                 if (\n                     agent.api_mode == \"codex_responses\""]},{"file":"agent/stream_diag.py","hunks":["@@ -258,7 +258,7 @@ def emit_stream_drop(\n         except Exception:\n             pass\n     try:\n-        agent._emit_status(\n+        agent._buffer_status(\n             f\"⚠️ {provider} stream {kind} ({type(error).__name__}){_suffix} \"\n             f\"— reconnecting, retry {attempt}/{max_attempts}\"\n         )"]},{"file":"run_agent.py","hunks":["@@ -801,6 +801,83 @@ def _emit_warning(self, message: str) -> None:\n             except Exception:\n                 logger.debug(\"status_callback error in _emit_warning\", exc_info=True)\n \n+    # ── Buffered retry/fallback status ────────────────────────────────────\n+    # Retry and fallback chains were flooding the CLI/gateway with status\n+    # noise that users found confusing: a single transient 429 could produce\n+    # 10+ \"Provider/Endpoint/Retrying in 5s...\" lines before the request\n+    # eventually succeeded.  The buffered helpers below capture these\n+    # status messages instead of emitting them immediately.  They are\n+    # flushed (shown to the user) ONLY when every retry and fallback has\n+    # been exhausted; on success they are silently dropped.  Backend logs\n+    # (agent.log) are unaffected — every individual emission site still\n+    # writes to ``logger.warning`` / ``logger.info`` for diagnosis.\n+\n+    def _buffer_status(self, message: str) -> None:\n+        \"\"\"Buffer a retry/fallback status message.\n+\n+        Stored as a (kind, text) tuple where ``kind`` is one of:\n+        - ``\"status\"``  -> replays via ``_emit_status``\n+        - ``\"vprint\"``  -> replays via ``_vprint(force=True)``\n+        - ``\"warn\"``    -> replays via ``_emit_warning``\n+        Used to defer noisy retry chatter until we know whether the\n+        turn ultimately recovered or failed.\n+        \"\"\"\n+        try:\n+            buf = getattr(self, \"_retry_status_buffer\", None)\n+            if buf is None:\n+                buf = []\n+                self._retry_status_buffer = buf\n+            buf.append((\"status\", message))\n+        except Exception:\n+            # Never break the retry loop on a buffer hiccup.\n+            pass\n+\n+    def _buffer_vprint(self, message: str) -> None:\n+        \"\"\"Buffer a vprint(force=True) retry/fallback line.\"\"\"\n+        try:\n+            buf = getattr(self, \"_retry_status_buffer\", None)\n+            if buf is None:\n+                buf = []\n+                self._retry_status_buffer = buf\n+            buf.append((\"vprint\", message))\n+        except Exception:\n+            pass\n+\n+    def _clear_status_buffer(self) -> None:\n+        \"\"\"Drop buffered retry messages — call on successful recovery.\"\"\"\n+        try:\n+            buf = getattr(self, \"_retry_status_buffer\", None)\n+            if buf:\n+                buf.clear()\n+        except Exception:\n+            pass\n+\n+    def _flush_status_buffer(self) -> None:\n+        \"\"\"Emit buffered retry messages — call on terminal failure.\n+\n+        Surfaces the full retry/fallback trace so the user can see what\n+        was tried before the turn gave up.\n+        \"\"\"\n+        try:\n+            buf = getattr(self, \"_retry_status_buffer\", None)\n+            if not buf:\n+                return\n+            # Drain first so a callback exception doesn't double-emit.\n+            messages = list(buf)\n+            buf.clear()\n+            for kind, msg in messages:\n+                try:\n+                    if kind == \"status\":\n+                        self._emit_status(msg)\n+                    elif kind == \"warn\":\n+                        self._emit_warning(msg)\n+                    else:\n+                        self._vprint(f\"{self.log_prefix}{msg}\", force=True)\n+                except Exception:\n+                    pass\n+        except Exception:\n+            pass\n+\n     def _disable_codex_reasoning_replay(\n         self,\n         messages: Optional[List[Dict[str, Any]]] = None,"]},{"file":"tests/agent/test_codex_ttfb_watchdog.py","hunks":["@@ -114,6 +114,7 @@ def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch):\n     statuses: list[str] = []\n     dummy_client = SimpleNamespace()\n     monkeypatch.setattr(agent, \"_create_request_openai_client\", lambda **k: dummy_client)\n+    monkeypatch.setattr(agent, \"_buffer_status\", lambda msg: statuses.append(msg))\n     monkeypatch.setattr(agent, \"_emit_status\", lambda msg: statuses.append(msg))\n     monkeypatch.setattr(\n         agent, \"_abort_request_openai_client\","]},{"file":"tests/run_agent/test_retry_status_buffer.py","hunks":["@@ -0,0 +1,157 @@\n+\"\"\"Tests for the retry/fallback status buffer helpers on AIAgent.\n+\n+These helpers defer noisy retry chatter (rate-limit retries, fallback\n+switches, compression attempts) so users only see the trace when\n+everything ultimately fails.  On successful recovery the buffer is\n+silently dropped.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_bare_agent():\n+    \"\"\"Construct an AIAgent without running __init__ — we only need the\n+    buffered-status helpers, which are pure-Python and depend only on a\n+    handful of attributes.\"\"\"\n+    agent = object.__new__(AIAgent)\n+    agent.log_prefix = \"\"\n+    agent.status_callback = None\n+    agent.suppress_status_output = False\n+    agent._mute_post_response = False\n+    agent._executing_tools = False\n+    agent._print_fn = None\n+    return agent\n+\n+\n+def test_buffer_status_accumulates_then_flushes(capsys):\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append((\"status\", msg))\n+\n+    agent._buffer_status(\"⏳ Retrying...\")\n+    agent._buffer_status(\"⚠️ Fallback...\")\n+\n+    # Nothing emitted yet — they are buffered.\n+    assert emitted == []\n+    assert agent._retry_status_buffer == [\n+        (\"status\", \"⏳ Retrying...\"),\n+        (\"status\", \"⚠️ Fallback...\"),\n+    ]\n+\n+    # Flush surfaces them in order through _emit_status.\n+    agent._flush_status_buffer()\n+    assert emitted == [\n+        (\"status\", \"⏳ Retrying...\"),\n+        (\"status\", \"⚠️ Fallback...\"),\n+    ]\n+    # Buffer is drained.\n+    assert agent._retry_status_buffer == []\n+\n+\n+def test_clear_drops_buffered_messages_silently():\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+\n+    agent._buffer_status(\"⏳ Retrying...\")\n+    agent._buffer_status(\"⚠️ Fallback...\")\n+    agent._clear_status_buffer()\n+\n+    # Nothing was emitted — clear is the success path.\n+    assert emitted == []\n+    assert agent._retry_status_buffer == []\n+\n+    # Subsequent flush is a no-op.\n+    agent._flush_status_buffer()\n+    assert emitted == []\n+\n+\n+def test_buffer_vprint_replays_via_vprint_with_log_prefix():\n+    agent = _make_bare_agent()\n+    agent.log_prefix = \"[abc] \"\n+    seen = []\n+    agent._vprint = lambda msg, force=False, **kw: seen.append((msg, force))\n+\n+    agent._buffer_vprint(\"⚠️  API call failed\")\n+    agent._flush_status_buffer()\n+\n+    # Replays through _vprint with force=True and the agent's log_prefix\n+    # prepended (matching the original direct-emit format).\n+    assert seen == [(\"[abc] ⚠️  API call failed\", True)]\n+\n+\n+def test_flush_empty_buffer_is_noop():\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+    agent._vprint = lambda msg, force=False, **kw: emitted.append(msg)\n+\n+    # No buffer attribute yet — flush should be a quiet no-op.\n+    agent._flush_status_buffer()\n+    assert emitted == []\n+\n+    # Even after touching the buffer (via clear on an empty/missing buffer).\n+    agent._clear_status_buffer()\n+    agent._flush_status_buffer()\n+    assert emitted == []\n+\n+\n+def test_re_buffer_after_flush_works():\n+    agent = _make_bare_agent()\n+    emitted = []\n+    agent._emit_status = lambda msg: emitted.append(msg)\n+\n+    agent._buffer_status(\"first\")\n+    agent._flush_status_buffer()\n+    agent._buffer_status(\"second\")\n+    agent._flush_status_buffer()\n+\n+    assert emitted == [\"first\", \"second\"]\n+\n+\n+def test_mixed_kinds_replay_through_correct_channels():\n+    agent = _make_bare_agent()\n+    agent.log_prefix = \"\"\n+    statuses = []\n+    vprints = []\n+    warns = []\n+    agent._emit_status = lambda msg: statuses.append(msg)\n+    agent._vprint = lambda msg, force=False, **kw: vprints.append((msg, force))\n+    agent._emit_warning = lambda msg: warns.append(msg)\n+\n+    agent._buffer_status(\"status-1\")\n+    agent._buffer_vprint(\"vprint-1\")\n+    # Manually mix in a \"warn\" record to verify the dispatch still works.\n+    agent._retry_status_buffer.append((\"warn\", \"warn-1\"))\n+    agent._buffer_status(\"status-2\")\n+\n+    agent._flush_status_buffer()\n+\n+    assert statuses == [\"status-1\", \"status-2\"]\n+    assert vprints == [(\"vprint-1\", True)]\n+    assert warns == [\"warn-1\"]\n+\n+\n+def test_flush_swallows_callback_exceptions():\n+    agent = _make_bare_agent()\n+    seen = []\n+\n+    def boom(msg):\n+        seen.append(msg)\n+        raise RuntimeError(\"simulated callback failure\")\n+\n+    agent._emit_status = boom\n+\n+    agent._buffer_status(\"first\")\n+    agent._buffer_status(\"second\")\n+    # Should not raise even though _emit_status raises for every message.\n+    agent._flush_status_buffer()\n+\n+    # Both messages were attempted.\n+    assert seen == [\"first\", \"second\"]\n+    # Buffer drained regardless of failures.\n+    assert agent._retry_status_buffer == []"]},{"file":"tests/run_agent/test_stream_drop_logging.py","hunks":["@@ -203,7 +203,7 @@ def test_emit_stream_drop_ui_includes_elapsed_when_available():\n     diag = AIAgent._stream_diag_init()\n     diag[\"started_at\"] = time.time() - 8.0  # 8s on the wire before drop\n \n-    with patch.object(agent, \"_emit_status\") as mock_emit:\n+    with patch.object(agent, \"_buffer_status\") as mock_emit:\n         agent._emit_stream_drop(\n             error=ConnectionError(\"x\"),\n             attempt=2,","@@ -223,7 +223,7 @@ def test_emit_stream_drop_ui_omits_suffix_without_diag():\n     agent = _make_agent()\n     agent.provider = \"openrouter\"\n \n-    with patch.object(agent, \"_emit_status\") as mock_emit:\n+    with patch.object(agent, \"_buffer_status\") as mock_emit:\n         agent._emit_stream_drop(\n             error=ConnectionError(\"x\"),\n             attempt=2,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}