fix(gateway): break compression-exhaustion infinite loop and auto-reset session (#9893)

When compression fails after max attempts, the agent returns
{completed: False, partial: True} but was missing the 'failed' flag.
The gateway's agent_failed_early guard checked for 'failed' AND
'not final_response', but _run_agent_blocking always converts errors
to final_response — making the guard dead code.  This caused the
oversized session to persist, creating an infinite fail loop where
every subsequent message hits the same compression failure.

Changes:
- run_agent.py: add 'failed: True' and 'compression_exhausted: True'
  to all 5 compression-exhaustion return paths
- gateway/run.py (_run_agent_blocking): forward 'failed' and
  'compression_exhausted' flags through to the caller
- gateway/run.py (_handle_message_with_agent): fix agent_failed_early
  to check bool(failed) without the broken 'not final_response' clause;
  auto-reset the session when compression is exhausted so the next
  message starts fresh
- Update tests to match new guard logic and add
  TestCompressionExhaustedFlag test class

Closes #9893
This commit is contained in:
Teknium
2026-04-14 21:15:12 -07:00
committed by Teknium
parent ba24f058ed
commit c5688e7c8b
3 changed files with 82 additions and 31 deletions

View File

@@ -3898,14 +3898,11 @@ class GatewayRunner:
# intermediate reasoning) so sessions can be resumed with full context
# and transcripts are useful for debugging and training data.
#
# IMPORTANT: When the agent failed before producing any response
# (e.g. context-overflow 400), do NOT persist the user's message.
# IMPORTANT: When the agent failed (e.g. context-overflow 400,
# compression exhausted), do NOT persist the user's message.
# Persisting it would make the session even larger, causing the
# same failure on the next attempt — an infinite loop. (#1630)
agent_failed_early = (
agent_result.get("failed")
and not agent_result.get("final_response")
)
# same failure on the next attempt — an infinite loop. (#1630, #9893)
agent_failed_early = bool(agent_result.get("failed"))
if agent_failed_early:
logger.info(
"Skipping transcript persistence for failed request in "
@@ -3913,6 +3910,24 @@ class GatewayRunner:
session_entry.session_id,
)
# When compression is exhausted, the session is permanently too
# large to process. Auto-reset it so the next message starts
# fresh instead of replaying the same oversized context in an
# infinite fail loop. (#9893)
if agent_result.get("compression_exhausted") and session_entry and session_key:
logger.info(
"Auto-resetting session %s after compression exhaustion.",
session_entry.session_id,
)
self.session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
response = (response or "") + (
"\n\n🔄 Session auto-reset — the conversation exceeded the "
"maximum context size and could not be compressed further. "
"Your next message will start a fresh session."
)
ts = datetime.now().isoformat()
# If this is a fresh session (no history), write the full tool
@@ -8626,6 +8641,8 @@ class GatewayRunner:
"final_response": error_msg,
"messages": result.get("messages", []),
"api_calls": result.get("api_calls", 0),
"failed": result.get("failed", False),
"compression_exhausted": result.get("compression_exhausted", False),
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"last_prompt_tokens": _last_prompt_toks,

View File

@@ -9312,7 +9312,9 @@ class AIAgent:
"completed": False,
"api_calls": api_call_count,
"error": f"Request payload too large: max compression attempts ({max_compression_attempts}) reached.",
"partial": True
"partial": True,
"failed": True,
"compression_exhausted": True,
}
self._emit_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...")
@@ -9341,7 +9343,9 @@ class AIAgent:
"completed": False,
"api_calls": api_call_count,
"error": "Request payload too large (413). Cannot compress further.",
"partial": True
"partial": True,
"failed": True,
"compression_exhausted": True,
}
# Check for context-length errors BEFORE generic 4xx handler.
@@ -9392,7 +9396,9 @@ class AIAgent:
"completed": False,
"api_calls": api_call_count,
"error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.",
"partial": True
"partial": True,
"failed": True,
"compression_exhausted": True,
}
restart_with_compressed_messages = True
break
@@ -9442,7 +9448,9 @@ class AIAgent:
"completed": False,
"api_calls": api_call_count,
"error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.",
"partial": True
"partial": True,
"failed": True,
"compression_exhausted": True,
}
self._emit_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
@@ -9473,7 +9481,9 @@ class AIAgent:
"completed": False,
"api_calls": api_call_count,
"error": f"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.",
"partial": True
"partial": True,
"failed": True,
"compression_exhausted": True,
}
# Check for non-retryable client errors. The classifier

View File

@@ -136,33 +136,29 @@ class TestGatewaySkipsPersistenceOnFailure:
the gateway should NOT persist messages to the transcript."""
def test_agent_failed_early_detected(self):
"""The agent_failed_early flag is True when failed=True and
no final_response."""
"""The agent_failed_early flag is True when failed=True,
regardless of final_response."""
agent_result = {
"failed": True,
"final_response": None,
"messages": [],
"error": "Non-retryable client error",
}
agent_failed_early = (
agent_result.get("failed")
and not agent_result.get("final_response")
)
agent_failed_early = bool(agent_result.get("failed"))
assert agent_failed_early
def test_agent_with_response_not_failed_early(self):
"""When the agent has a final_response, it's not a failed-early
scenario even if failed=True."""
def test_agent_failed_with_error_response_still_detected(self):
"""When _run_agent_blocking converts an error to final_response,
the failed flag should still trigger agent_failed_early. This
was the core bug in #9893 — the old guard checked
``not final_response`` which was always truthy after conversion."""
agent_result = {
"failed": True,
"final_response": "Here is a partial response",
"final_response": "⚠️ Request payload too large: max compression attempts reached.",
"messages": [],
}
agent_failed_early = (
agent_result.get("failed")
and not agent_result.get("final_response")
)
assert not agent_failed_early
agent_failed_early = bool(agent_result.get("failed"))
assert agent_failed_early
def test_successful_agent_not_failed_early(self):
"""A successful agent result should not trigger skip."""
@@ -170,13 +166,41 @@ class TestGatewaySkipsPersistenceOnFailure:
"final_response": "Hello!",
"messages": [{"role": "assistant", "content": "Hello!"}],
}
agent_failed_early = (
agent_result.get("failed")
and not agent_result.get("final_response")
)
agent_failed_early = bool(agent_result.get("failed"))
assert not agent_failed_early
class TestCompressionExhaustedFlag:
"""When compression is exhausted, the agent should set both
failed=True and compression_exhausted=True so the gateway can
auto-reset the session. (#9893)"""
def test_compression_exhausted_returns_carry_flag(self):
"""Simulate the return dict from a compression-exhausted agent."""
agent_result = {
"messages": [],
"completed": False,
"api_calls": 3,
"error": "Request payload too large: max compression attempts (3) reached.",
"partial": True,
"failed": True,
"compression_exhausted": True,
}
assert agent_result.get("failed")
assert agent_result.get("compression_exhausted")
def test_normal_failure_not_compression_exhausted(self):
"""Non-compression failures should not have compression_exhausted."""
agent_result = {
"messages": [],
"completed": False,
"failed": True,
"error": "Invalid API response after 3 retries",
}
assert agent_result.get("failed")
assert not agent_result.get("compression_exhausted")
# ---------------------------------------------------------------------------
# Test 3: Context-overflow error messages
# ---------------------------------------------------------------------------