fix(compress): abort instead of dropping messages when summary LLM fails (#28102)

When auxiliary compression's summary generation returns None (aux model
errored, returned non-JSON, timed out, etc.) the compressor previously
still dropped every middle message between compress_start..compress_end
and replaced them with a static 'Summary generation was unavailable'
placeholder. The session kept going but the user silently lost N turns
of context for nothing.

New behavior: on summary failure, compress() aborts entirely — returns
the input messages unchanged and sets _last_compress_aborted=True. The
existing _summary_failure_cooldown_until gate (30-60s) keeps the aux
model from being burned on every turn. Auto-compress callers detect
the no-op (len(after) == len(before)) and stop looping. The chat is
'frozen' at its current size until the next /compress or /new.

Manual /compress (CLI + gateway) now passes force=True which clears
the cooldown so users can retry immediately after an auto-abort. If
the manual retry also fails, the user gets a visible warning telling
them nothing was dropped and how to retry.

- agent/context_compressor.py: compress() gains force= kwarg; failure
  branch sets _last_compress_aborted and returns messages unchanged
  instead of inserting placeholder.
- run_agent.py: _compress_context() detects abort, surfaces warning,
  skips session-rotation entirely, returns messages unchanged.
- cli.py + gateway/run.py: manual /compress paths pass force=True.
- gateway/run.py: hygiene + /compress handlers detect _last_compress_aborted
  and emit the new 'Compression aborted' warning (gateway.compress.aborted)
  instead of the old 'N historical messages were removed' message.
- locales/*.yaml: new gateway.compress.aborted key in all 16 locales.
- tests: updated to assert the abort contract (messages preserved,
  compression_count not incremented, abort flag set, no placeholder
  leaked). New test_force_true_bypasses_failure_cooldown covers the
  manual-retry path.
This commit is contained in:
Teknium
2026-05-18 10:19:40 -07:00
committed by GitHub
parent 65e0c49b77
commit 1634397ddb
24 changed files with 249 additions and 103 deletions
+20 -24
View File
@@ -130,19 +130,15 @@ async def test_compress_command_explains_when_token_estimate_rises():
@pytest.mark.asyncio
async def test_compress_command_appends_warning_when_summary_generation_fails():
"""When the auxiliary summariser fails and the compressor inserts a static
fallback placeholder, /compress must append a visible ⚠️ warning to its
reply. Otherwise the failure is silently logged and the user has no idea
earlier context is unrecoverable."""
async def test_compress_command_appends_warning_when_compression_aborts():
"""When the auxiliary summariser fails and the compressor ABORTS (returns
messages unchanged), /compress must append a visible ⚠️ warning to its
reply telling the user nothing was dropped and how to retry. Otherwise
the failure is silently logged and the user has no idea why nothing
happened."""
history = _make_history()
# Compressed shape is irrelevant for this test — we only care that the
# warning surfaces. Drop one message so the headline is non-noop.
compressed = [
history[0],
{"role": "assistant", "content": "[fallback placeholder]"},
history[-1],
]
# Abort path: compressor returns the input messages unchanged.
compressed = list(history)
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
@@ -150,10 +146,11 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
# Simulate summary-generation failure: fallback flag set, dropped count
# populated, error string captured.
agent_instance.context_compressor._last_summary_fallback_used = True
agent_instance.context_compressor._last_summary_dropped_count = 7
# Simulate compression aborting (force=True bypassed cooldown but the
# aux LLM is genuinely broken).
agent_instance.context_compressor._last_compress_aborted = True
agent_instance.context_compressor._last_summary_fallback_used = False
agent_instance.context_compressor._last_summary_dropped_count = 0
agent_instance.context_compressor._last_summary_error = (
"404 model not found: gemini-3-flash-preview"
)
@@ -164,7 +161,7 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
if messages == history:
return 100
if messages == compressed:
return 60
return 100
raise AssertionError(f"unexpected transcript: {messages!r}")
with (
@@ -175,16 +172,14 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
):
result = await runner._handle_compress_command(_make_event())
# The compress reply itself still goes through (the transcript was rewritten).
assert "Compressed:" in result
# ...but a clearly-marked warning must be appended.
# A clearly-marked warning must be appended.
assert "⚠️" in result
assert "Summary generation failed" in result
assert "Compression aborted" in result
# Underlying error must surface so users can fix their config.
assert "404 model not found" in result
# Dropped count must be visible — silently losing N messages is the bug.
assert "7" in result
assert "historical message(s) were removed" in result
# User must be told nothing was dropped — the whole point of the
# new behavior is no silent data loss.
assert "No messages were dropped" in result
agent_instance.shutdown_memory_provider.assert_called_once()
agent_instance.close.assert_called_once()
@@ -210,6 +205,7 @@ async def test_compress_command_surfaces_aux_model_failure_even_when_recovered()
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
# Fallback placeholder was NOT used — recovery succeeded.
agent_instance.context_compressor._last_compress_aborted = False
agent_instance.context_compressor._last_summary_fallback_used = False
agent_instance.context_compressor._last_summary_dropped_count = 0
agent_instance.context_compressor._last_summary_error = None