From 1e895f4c17ef12e14a1c654a7ebd958870774397 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:30:47 -0600 Subject: [PATCH] fix(context): harden compression failure feedback --- agent/context_compressor.py | 35 ++++++++++--------- agent/manual_compression_feedback.py | 5 ++- .../agent/test_manual_compression_feedback.py | 22 ++++++++++++ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e9e701b14506..7c7b5ec330d6 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2364,8 +2364,10 @@ This compaction should PRIORITISE preserving all information related to the focu # from a distinct auxiliary summary_model; only a failure on the # main model — or a fallback that also access/quota-fails — makes # the abort stick. - _is_auth_error = _is_summary_access_or_quota_error(e) - if _is_auth_error: + _is_access_or_quota_error = _is_summary_access_or_quota_error(e) + if _is_access_or_quota_error: + # Keep the established field name for caller compatibility; + # it now represents the broader terminal access/quota class. self._last_summary_auth_failure = True if _is_json_decode and not _is_model_not_found and not _is_timeout: logger.error( @@ -3251,16 +3253,14 @@ This compaction should PRIORITISE preserving all information related to the focu # surface a warning. # Default is False (historical behavior). # - # EXCEPTION — auth AND transient network failures always abort. A - # 401/403 from the summary call means the credential or endpoint is - # broken (invalid/blocked key, or a token pointed at the wrong - # inference host). A connection/stream-close error means the network - # blipped at the compaction moment (#29559). In BOTH cases rotating into - # a child session with a placeholder summary on a broken credential - # strands the user on a degraded session for zero benefit — every - # subsequent call fails the same way. So when the failure was an auth - # error we abort regardless of abort_on_summary_failure, preserving - # the conversation unchanged until the credential is fixed. + # EXCEPTION — terminal access/quota AND transient network failures + # always abort. Missing credentials, 401/402/403 access failures, and + # confirmed non-resetting quota exhaustion cannot be repaired by + # retrying the same summary request. A connection/stream-close error + # means the network blipped at the compaction moment (#29559). In all + # of these cases, rotating into a child session with a placeholder + # summary degrades the conversation for zero benefit. Preserve it + # unchanged until access is restored or connectivity recovers. if not summary and ( self.abort_on_summary_failure or self._last_summary_auth_failure @@ -3273,11 +3273,12 @@ This compaction should PRIORITISE preserving all information related to the focu if not self.quiet_mode: if self._last_summary_auth_failure: logger.warning( - "Summary generation failed with an authentication " - "error — aborting compression. %d message(s) preserved " - "unchanged; the session was NOT rotated. Check your " - "provider credential / inference endpoint, then retry " - "with /compress or start fresh with /new.", + "Summary generation failed with a terminal access or " + "quota error — aborting compression. %d message(s) " + "preserved unchanged; the session was NOT rotated. " + "Check the provider credential, permission, quota, or " + "inference endpoint, then retry with /compress or " + "start fresh with /new.", n_skipped, ) elif self._last_summary_network_failure: diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py index 9a40cc36df1e..91ada6ec4909 100644 --- a/agent/manual_compression_feedback.py +++ b/agent/manual_compression_feedback.py @@ -74,7 +74,10 @@ def summarize_manual_compression( ) if failure_reason and (aborted or fallback_used): - safe_reason = redact_sensitive_text(failure_reason.strip()) + # This text crosses a user-facing UI boundary. Never let a disabled + # global redaction preference expose credentials embedded in provider + # exception text. + safe_reason = redact_sensitive_text(failure_reason.strip(), force=True) note = f"{note} Reason: {safe_reason}" return { diff --git a/tests/agent/test_manual_compression_feedback.py b/tests/agent/test_manual_compression_feedback.py index 51f9fdf7b651..b8a80da7c1a8 100644 --- a/tests/agent/test_manual_compression_feedback.py +++ b/tests/agent/test_manual_compression_feedback.py @@ -37,6 +37,28 @@ def test_aborted_compression_reports_preserved_messages_and_reason(): assert "no API key was found" in feedback["note"] +def test_failure_reason_redaction_is_forced_at_ui_boundary(monkeypatch): + messages = _messages(12) + fake_secret = "sk-proj-" + "X" * 40 + state = SimpleNamespace( + _last_compress_aborted=True, + _last_summary_fallback_used=False, + _last_summary_error=f"provider rejected OPENAI_API_KEY={fake_secret}", + ) + monkeypatch.setattr("agent.redact._REDACT_ENABLED", False, raising=False) + + feedback = summarize_manual_compression( + messages, + list(messages), + 120_000, + 120_000, + compression_state=state, + ) + + assert fake_secret not in feedback["note"] + assert "OPENAI_API_KEY=" in feedback["note"] + + def test_fallback_compression_reports_dropped_message_count(): before = _messages(12) after = before[:2] + before[-2:]