fix(context): harden compression failure feedback

This commit is contained in:
Gille 2026-07-15 14:30:47 -06:00 committed by kshitij
parent 577beeb9b9
commit 1e895f4c17
3 changed files with 44 additions and 18 deletions

View file

@ -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:

View file

@ -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 {

View file

@ -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:]