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:
@@ -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,
|
||||
|
||||
20
run_agent.py
20
run_agent.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user