diff --git a/gateway/run.py b/gateway/run.py index c59e9dff1..1bef295c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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, diff --git a/run_agent.py b/run_agent.py index 0814a8b49..080156051 100644 --- a/run_agent.py +++ b/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 diff --git a/tests/run_agent/test_1630_context_overflow_loop.py b/tests/run_agent/test_1630_context_overflow_loop.py index d087fee4f..c33aaa967 100644 --- a/tests/run_agent/test_1630_context_overflow_loop.py +++ b/tests/run_agent/test_1630_context_overflow_loop.py @@ -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 # ---------------------------------------------------------------------------