fix(context): preserve missing-key compression history

fix/verification-admin-route-recovery
Gille 2026-07-15 14:20:42 -06:00 committed by kshitij
parent 202ad1b8c9
commit 577beeb9b9
8 changed files with 251 additions and 17 deletions

View File

@ -47,6 +47,11 @@ _SUMMARY_PERMANENT_QUOTA_MARKERS: tuple[str, ...] = (
"out of extra usage", "out of extra usage",
) )
_SUMMARY_MISSING_CREDENTIAL_MARKERS: tuple[str, ...] = (
"no api key was found",
"no api key found",
)
def _is_summary_access_or_quota_error(exc: Exception) -> bool: def _is_summary_access_or_quota_error(exc: Exception) -> bool:
"""Return True for non-retryable summary auth, permission, or quota errors.""" """Return True for non-retryable summary auth, permission, or quota errors."""
@ -57,13 +62,16 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool:
if classified.reason in {FailoverReason.auth, FailoverReason.auth_permanent}: if classified.reason in {FailoverReason.auth, FailoverReason.auth_permanent}:
return True return True
err_text = str(exc).lower()
if any(marker in err_text for marker in _SUMMARY_MISSING_CREDENTIAL_MARKERS):
return True
status = getattr(exc, "status_code", None) or getattr( status = getattr(exc, "status_code", None) or getattr(
getattr(exc, "response", None), "status_code", None getattr(exc, "response", None), "status_code", None
) )
if status in {401, 402, 403}: if status in {401, 402, 403}:
return True return True
err_text = str(exc).lower()
if classified.reason is FailoverReason.billing: if classified.reason is FailoverReason.billing:
return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS)
return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS)

View File

@ -4,45 +4,83 @@ from __future__ import annotations
from typing import Any, Sequence from typing import Any, Sequence
from agent.redact import redact_sensitive_text
def summarize_manual_compression( def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]], before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]], after_messages: Sequence[dict[str, Any]],
before_tokens: int, before_tokens: int,
after_tokens: int, after_tokens: int,
*,
compression_state: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return consistent user-facing feedback for manual compression.""" """Return consistent user-facing feedback for manual compression."""
before_count = len(before_messages) before_count = len(before_messages)
after_count = len(after_messages) after_count = len(after_messages)
noop = list(after_messages) == list(before_messages) noop = list(after_messages) == list(before_messages)
aborted = (
compression_state is not None
and getattr(compression_state, "_last_compress_aborted", False) is True
)
fallback_used = (
compression_state is not None
and getattr(compression_state, "_last_summary_fallback_used", False) is True
)
failure_reason = (
getattr(compression_state, "_last_summary_error", None)
if compression_state is not None
else None
)
if not isinstance(failure_reason, str) or not failure_reason.strip():
failure_reason = None
if noop: if aborted:
headline = f"Compression aborted: {before_count} messages preserved"
elif fallback_used:
headline = (
f"Compressed with fallback: {before_count}{after_count} messages"
)
elif noop:
headline = f"No changes from compression: {before_count} messages" headline = f"No changes from compression: {before_count} messages"
if after_tokens == before_tokens:
token_line = (
f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
)
else:
token_line = (
f"Approx request size: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
else: else:
headline = f"Compressed: {before_count}{after_count} messages" headline = f"Compressed: {before_count}{after_count} messages"
if noop and after_tokens == before_tokens:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
else:
token_line = ( token_line = (
f"Approx request size: ~{before_tokens:,}" f"Approx request size: ~{before_tokens:,}"
f"~{after_tokens:,} tokens" f"~{after_tokens:,} tokens"
) )
note = None note = None
if not noop and after_count < before_count and after_tokens > before_tokens: if aborted:
note = "Summary generation failed; no messages were removed."
elif fallback_used:
dropped_count = getattr(
compression_state, "_last_summary_dropped_count", None
)
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
dropped_count = max(before_count - after_count, 0)
note = (
"Summary generation failed; Hermes used limited fallback context "
f"and removed {dropped_count} message(s)."
)
elif not noop and after_count < before_count and after_tokens > before_tokens:
note = ( note = (
"Note: fewer messages can still raise this estimate when " "Note: fewer messages can still raise this estimate when "
"compression rewrites the transcript into denser summaries." "compression rewrites the transcript into denser summaries."
) )
if failure_reason and (aborted or fallback_used):
safe_reason = redact_sensitive_text(failure_reason.strip())
note = f"{note} Reason: {safe_reason}"
return { return {
"noop": noop, "noop": noop,
"aborted": aborted,
"fallback_used": fallback_used,
"headline": headline, "headline": headline,
"token_line": token_line, "token_line": token_line,
"note": note, "note": note,

6
cli.py
View File

@ -9641,7 +9641,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.conversation_history, self.conversation_history,
approx_tokens, approx_tokens,
new_tokens, new_tokens,
compression_state=getattr(
self.agent, "context_compressor", None
),
) )
if summary.get("aborted") or summary.get("fallback_used"):
icon = "⚠️"
else:
icon = "🗜️" if summary["noop"] else "" icon = "🗜️" if summary["noop"] else ""
print(f" {icon} {summary['headline']}") print(f" {icon} {summary['headline']}")
print(f" {summary['token_line']}") print(f" {summary['token_line']}")

View File

@ -854,11 +854,19 @@ class TestAuthFailureAborts:
def test_quota_classifier_accepts_explicit_provider_signals(self, message): def test_quota_classifier_accepts_explicit_provider_signals(self, message):
assert _is_summary_access_or_quota_error(Exception(message)) is True assert _is_summary_access_or_quota_error(Exception(message)) is True
def test_missing_provider_api_key_is_terminal_access_failure(self):
err = RuntimeError(
"Provider 'opencode-zen' is set in config.yaml but no API key was "
"found. Set the OPENCODE-ZEN_API_KEY environment variable."
)
assert _is_summary_access_or_quota_error(err) is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
"message", "message",
[ [
"billing portal is temporarily unavailable", "billing portal is temporarily unavailable",
"usage limit documentation could not be loaded", "usage limit documentation could not be loaded",
"API key documentation was not found",
"rate limit exceeded; retry later", "rate limit exceeded; retry later",
"quota exceeded, please retry after the window resets", "quota exceeded, please retry after the window resets",
"request timed out", "request timed out",
@ -905,6 +913,34 @@ class TestAuthFailureAborts:
assert c._last_compress_aborted is True assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False assert c._last_summary_fallback_used is False
def test_missing_provider_api_key_preserves_original_messages(self):
"""A configured auxiliary provider without a visible key preserves context."""
err = RuntimeError(
"Provider 'opencode-zen' is set in config.yaml but no API key was "
"found. Set the OPENCODE-ZEN_API_KEY environment variable, or switch "
"to a different provider with hermes model."
)
with patch(
"agent.context_compressor.get_model_context_length", return_value=100000
):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=err):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_error == str(err)
assert c._last_summary_auth_failure is True
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
assert c._last_summary_dropped_count == 0
def test_402_quota_with_retry_uses_existing_fallback(self): def test_402_quota_with_retry_uses_existing_fallback(self):
"""A reset-window quota remains transient instead of aborting compression.""" """A reset-window quota remains transient instead of aborting compression."""
err = StubProviderError( err = StubProviderError(

View File

@ -0,0 +1,62 @@
"""Behavioral coverage for manual compression status messages."""
from types import SimpleNamespace
from agent.manual_compression_feedback import summarize_manual_compression
def _messages(count: int) -> list[dict[str, str]]:
return [
{"role": "user" if index % 2 == 0 else "assistant", "content": str(index)}
for index in range(count)
]
def test_aborted_compression_reports_preserved_messages_and_reason():
messages = _messages(12)
state = SimpleNamespace(
_last_compress_aborted=True,
_last_summary_fallback_used=False,
_last_summary_error=(
"Provider 'opencode-zen' is set in config.yaml but no API key was found."
),
)
feedback = summarize_manual_compression(
messages,
list(messages),
120_000,
120_000,
compression_state=state,
)
assert feedback["aborted"] is True
assert feedback["fallback_used"] is False
assert feedback["headline"] == "Compression aborted: 12 messages preserved"
assert "no messages were removed" in feedback["note"]
assert "no API key was found" in feedback["note"]
def test_fallback_compression_reports_dropped_message_count():
before = _messages(12)
after = before[:2] + before[-2:]
state = SimpleNamespace(
_last_compress_aborted=False,
_last_summary_fallback_used=True,
_last_summary_dropped_count=8,
_last_summary_error="summary provider returned an invalid response",
)
feedback = summarize_manual_compression(
before,
after,
120_000,
40_000,
compression_state=state,
)
assert feedback["aborted"] is False
assert feedback["fallback_used"] is True
assert feedback["headline"] == "Compressed with fallback: 12 → 4 messages"
assert "removed 8 message(s)" in feedback["note"]
assert "invalid response" in feedback["note"]

View File

@ -38,6 +38,32 @@ def test_manual_compress_reports_noop_without_success_banner(capsys):
assert "Approx request size: ~100 tokens (unchanged)" in output assert "Approx request size: ~100 tokens (unchanged)" in output
def test_manual_compress_reports_aborted_summary_without_success_banner(capsys):
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.tools = None
shell.agent.session_id = shell.session_id
shell.agent.context_compressor._last_compress_aborted = True
shell.agent.context_compressor._last_summary_fallback_used = False
shell.agent.context_compressor._last_summary_error = (
"Provider 'opencode-zen' is set in config.yaml but no API key was found."
)
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
output = capsys.readouterr().out
assert "⚠️ Compression aborted: 4 messages preserved" in output
assert "no messages were removed" in output
assert "no API key was found" in output
assert "✅ Compressed:" not in output
def test_manual_compress_explains_when_token_estimate_rises(capsys): def test_manual_compress_explains_when_token_estimate_rises(capsys):
shell = _make_cli() shell = _make_cli()
history = _make_history() history = _make_history()

View File

@ -4759,6 +4759,52 @@ def test_session_compress_uses_compress_helper(monkeypatch):
emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"}) emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})
def test_session_compress_reports_aborted_summary_without_success(monkeypatch):
compression_state = types.SimpleNamespace(
_last_compress_aborted=True,
_last_summary_fallback_used=False,
_last_summary_error=(
"Provider 'opencode-zen' is set in config.yaml but no API key was found."
),
)
agent = types.SimpleNamespace(
context_compressor=compression_state,
_cached_system_prompt="",
tools=None,
)
history = [{"role": "user", "content": f"m{i}"} for i in range(6)]
server._sessions["sid"] = _session(agent=agent, history=history)
monkeypatch.setattr(
server,
"_compress_session_history",
lambda session, focus_topic=None, **_kw: (0, {"total": 42}),
)
monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"})
try:
with patch("tui_gateway.server._emit"):
resp = server.handle_request(
{
"id": "1",
"method": "session.compress",
"params": {"session_id": "sid"},
}
)
result = resp["result"]
assert result["status"] == "aborted"
assert result["removed"] == 0
assert result["summary"]["aborted"] is True
assert result["summary"]["headline"] == (
"Compression aborted: 6 messages preserved"
)
assert "no API key was found" in result["summary"]["note"]
assert "Compressed:" not in result["summary"]["headline"]
finally:
server._sessions.pop("sid", None)
def test_session_compress_syncs_session_key_after_rotation(monkeypatch): def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
"""When AIAgent._compress_context rotates session_id (compression split), """When AIAgent._compress_context rotates session_id (compression split),
the gateway session_key must follow so subsequent approval routing, the gateway session_key must follow so subsequent approval routing,

View File

@ -7976,14 +7976,18 @@ def _(rid, params: dict) -> dict:
agent = session["agent"] agent = session["agent"]
_sync_session_key_after_compress(sid, session) _sync_session_key_after_compress(sid, session)
summary = summarize_manual_compression( summary = summarize_manual_compression(
before_messages, messages, before_tokens, after_tokens before_messages,
messages,
before_tokens,
after_tokens,
compression_state=getattr(agent, "context_compressor", None),
) )
info = _session_info(agent, session) info = _session_info(agent, session)
_emit("session.info", sid, info) _emit("session.info", sid, info)
return _ok( return _ok(
rid, rid,
{ {
"status": "compressed", "status": "aborted" if summary["aborted"] else "compressed",
"removed": removed, "removed": removed,
"before_messages": before_count, "before_messages": before_count,
"after_messages": after_count, "after_messages": after_count,
@ -12442,7 +12446,11 @@ def _(rid, params: dict) -> dict:
) )
_sync_session_key_after_compress(sid, session) _sync_session_key_after_compress(sid, session)
summary = summarize_manual_compression( summary = summarize_manual_compression(
before_messages, after_messages, before_tokens, after_tokens before_messages,
after_messages,
before_tokens,
after_tokens,
compression_state=getattr(_agent, "context_compressor", None),
) )
_emit("session.info", sid, _session_info(session.get("agent"), session)) _emit("session.info", sid, _session_info(session.get("agent"), session))
return _ok( return _ok(
@ -13215,7 +13223,11 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
) )
_emit("session.info", sid, _session_info(agent, session)) _emit("session.info", sid, _session_info(agent, session))
_fb = summarize_manual_compression( _fb = summarize_manual_compression(
_before_messages, _after_messages, _before_tokens, _after_tokens _before_messages,
_after_messages,
_before_tokens,
_after_tokens,
compression_state=getattr(agent, "context_compressor", None),
) )
_lines = [_fb["headline"], _fb["token_line"]] _lines = [_fb["headline"], _fb["token_line"]]
if _fb.get("note"): if _fb.get("note"):