"""Regression tests: overflow recovery handlers must pass overhead-aware token estimates. PR fix (LCM issue 441): 413, context-overflow, and long-context-tier recovery handlers were passing a messages-only token estimate to _compress_context instead of the overhead-aware estimate_request_tokens_rough(api_messages, tools=agent.tools or None), which includes tool schemas and system prompt overhead. These tests assert that each recovery handler: 1. Calls estimate_request_tokens_rough with a non-None `tools` argument. 2. Passes the resulting value as approx_tokens to _compress_context. The sentinel pattern (return_value=987654) makes the assertion unambiguous: if approx_tokens==987654 the overhead-aware path was taken; any other value means the handler used a different (likely messages-only) estimate. """ import pytest from types import SimpleNamespace from unittest.mock import MagicMock, patch, call from run_agent import AIAgent import run_agent # --------------------------------------------------------------------------- # Shared fixtures / helpers (mirrored from test_413_compression.py) # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _no_sleep(monkeypatch): """Short-circuit all time.sleep and jittered_backoff calls.""" import time as _time monkeypatch.setattr(_time, "sleep", lambda *_a, **_k: None) monkeypatch.setattr(run_agent, "jittered_backoff", lambda *a, **k: 0.0) def _make_tool_defs(*names: str) -> list: return [ { "type": "function", "function": { "name": n, "description": f"{n} tool", "parameters": {"type": "object", "properties": {}}, }, } for n in names ] def _mock_response(content="Hello", finish_reason="stop", tool_calls=None, usage=None): msg = SimpleNamespace( content=content, tool_calls=tool_calls, reasoning_content=None, reasoning=None, ) choice = SimpleNamespace(message=msg, finish_reason=finish_reason) resp = SimpleNamespace(choices=[choice], model="test/model") resp.usage = SimpleNamespace(**usage) if usage else None return resp @pytest.fixture() def agent(): with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), ): a = AIAgent( api_key="test-key-1234567890", base_url="https://openrouter.ai/api/v1", quiet_mode=True, skip_context_files=True, skip_memory=True, ) a.client = MagicMock() a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False a.compression_enabled = True a.save_trajectories = False return a def _prefill(): return [ {"role": "user", "content": "previous question"}, {"role": "assistant", "content": "previous answer"}, ] # --------------------------------------------------------------------------- # Sentinel: any value that could not coincidentally appear from a messages-only # estimate during these tests. # --------------------------------------------------------------------------- _SENTINEL_TOKENS = 987_654 # --------------------------------------------------------------------------- # 1. 413 / payload-too-large handler # --------------------------------------------------------------------------- class TestHTTP413OverheadAwareTokens: """The 413 recovery handler must call estimate_request_tokens_rough with tools=agent.tools (non-None) and pass the result as approx_tokens.""" def test_413_passes_overhead_aware_tokens_to_compress(self, agent): """approx_tokens passed to _compress_context equals the overhead-aware estimate.""" err = Exception("Request entity too large") err.status_code = 413 ok_resp = _mock_response(content="Success", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ) as mock_estimate, patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) result = agent.run_conversation("hello", conversation_history=_prefill()) # _compress_context must have been called at least once for compression mock_compress.assert_called() # Find the call that came from the 413 handler (approx_tokens=sentinel) compress_kwargs_list = [c.kwargs for c in mock_compress.call_args_list] sentinel_call = next( (kw for kw in compress_kwargs_list if kw.get("approx_tokens") == _SENTINEL_TOKENS), None, ) assert sentinel_call is not None, ( f"No _compress_context call received approx_tokens={_SENTINEL_TOKENS}. " f"Calls received approx_tokens values: " f"{[kw.get('approx_tokens') for kw in compress_kwargs_list]}" ) def test_413_estimate_called_with_non_none_tools(self, agent): """estimate_request_tokens_rough must receive tools= in the 413 handler.""" err = Exception("Request entity too large") err.status_code = 413 ok_resp = _mock_response(content="Success", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] estimate_calls = [] def _capture_estimate(messages, tools=None): estimate_calls.append({"messages": messages, "tools": tools}) return _SENTINEL_TOKENS with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) agent.run_conversation("hello", conversation_history=_prefill()) # At least one estimate call from the 413 handler must have non-None tools handler_calls_with_tools = [c for c in estimate_calls if c["tools"] is not None] assert handler_calls_with_tools, ( "estimate_request_tokens_rough was never called with non-None tools " "during 413 recovery. All calls: " + str([c["tools"] for c in estimate_calls]) ) # --------------------------------------------------------------------------- # 2. Context-overflow / input-too-large handler # --------------------------------------------------------------------------- class TestContextOverflowOverheadAwareTokens: """The context-overflow (input overflow) recovery handler must call estimate_request_tokens_rough with tools=agent.tools and pass the result as approx_tokens to _compress_context.""" @staticmethod def _make_context_overflow_error(): """Build a 400 error that the classifier routes to context_overflow.""" err = Exception( "Error code: 400 - {'error': {'message': " "\"This endpoint's maximum context length is 128000 tokens. " "However, you requested about 200000 tokens. " "Please reduce the length of the messages.\"}}" ) err.status_code = 400 return err def test_context_overflow_passes_overhead_aware_tokens_to_compress(self, agent): """approx_tokens passed to _compress_context equals the overhead-aware estimate.""" err = self._make_context_overflow_error() ok_resp = _mock_response(content="Recovered", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ) as mock_estimate, patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) result = agent.run_conversation("hello", conversation_history=_prefill()) mock_compress.assert_called() compress_kwargs_list = [c.kwargs for c in mock_compress.call_args_list] sentinel_call = next( (kw for kw in compress_kwargs_list if kw.get("approx_tokens") == _SENTINEL_TOKENS), None, ) assert sentinel_call is not None, ( f"No _compress_context call received approx_tokens={_SENTINEL_TOKENS}. " f"Calls received approx_tokens values: " f"{[kw.get('approx_tokens') for kw in compress_kwargs_list]}" ) def test_context_overflow_estimate_called_with_non_none_tools(self, agent): """estimate_request_tokens_rough must receive tools= in the context-overflow handler.""" err = self._make_context_overflow_error() ok_resp = _mock_response(content="Recovered", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] estimate_calls = [] def _capture_estimate(messages, tools=None): estimate_calls.append({"messages": messages, "tools": tools}) return _SENTINEL_TOKENS with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) agent.run_conversation("hello", conversation_history=_prefill()) handler_calls_with_tools = [c for c in estimate_calls if c["tools"] is not None] assert handler_calls_with_tools, ( "estimate_request_tokens_rough was never called with non-None tools " "during context-overflow recovery. All calls: " + str([c["tools"] for c in estimate_calls]) ) def test_prompt_too_long_variant_passes_overhead_aware_tokens(self, agent): """Anthropic 'prompt is too long' error also routes to context_overflow handler.""" err = Exception( "Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error', " "'message': 'prompt is too long: 233153 tokens > 200000 maximum'}}" ) err.status_code = 400 ok_resp = _mock_response(content="Recovered", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) result = agent.run_conversation("hello", conversation_history=_prefill()) mock_compress.assert_called() compress_kwargs_list = [c.kwargs for c in mock_compress.call_args_list] sentinel_call = next( (kw for kw in compress_kwargs_list if kw.get("approx_tokens") == _SENTINEL_TOKENS), None, ) assert sentinel_call is not None, ( f"'prompt is too long' path did not pass overhead-aware approx_tokens. " f"Got: {[kw.get('approx_tokens') for kw in compress_kwargs_list]}" ) # --------------------------------------------------------------------------- # 3. Anthropic long-context tier (429) handler # --------------------------------------------------------------------------- class TestLongContextTierOverheadAwareTokens: """The Anthropic long-context-tier 429 handler must call estimate_request_tokens_rough with tools=agent.tools and pass the result as approx_tokens to _compress_context.""" @staticmethod def _make_long_context_tier_error(): """Build a 429 'extra usage required for long context requests' error.""" err = Exception( "Error code: 429 - {'error': {'type': 'rate_limit_error', " "'message': 'Extra usage is required for long context requests. " "Please enable extra usage in your account settings.'}}" ) err.status_code = 429 return err def test_long_context_tier_passes_overhead_aware_tokens_to_compress(self, agent): """approx_tokens passed to _compress_context equals the overhead-aware estimate.""" err = self._make_long_context_tier_error() ok_resp = _mock_response(content="Recovered after context tier", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", return_value=_SENTINEL_TOKENS, ), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) result = agent.run_conversation("hello", conversation_history=_prefill()) mock_compress.assert_called() compress_kwargs_list = [c.kwargs for c in mock_compress.call_args_list] sentinel_call = next( (kw for kw in compress_kwargs_list if kw.get("approx_tokens") == _SENTINEL_TOKENS), None, ) assert sentinel_call is not None, ( f"Long-context-tier handler did not pass overhead-aware approx_tokens. " f"Got: {[kw.get('approx_tokens') for kw in compress_kwargs_list]}" ) def test_long_context_tier_estimate_called_with_non_none_tools(self, agent): """estimate_request_tokens_rough must receive tools= in the long-context handler.""" err = self._make_long_context_tier_error() ok_resp = _mock_response(content="Recovered", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err, ok_resp] estimate_calls = [] def _capture_estimate(messages, tools=None): estimate_calls.append({"messages": messages, "tools": tools}) return _SENTINEL_TOKENS with ( patch( "agent.conversation_loop.estimate_request_tokens_rough", side_effect=_capture_estimate, ), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): mock_compress.return_value = ( [{"role": "user", "content": "compressed"}], "compressed prompt", ) agent.run_conversation("hello", conversation_history=_prefill()) handler_calls_with_tools = [c for c in estimate_calls if c["tools"] is not None] assert handler_calls_with_tools, ( "estimate_request_tokens_rough was never called with non-None tools " "during long-context-tier recovery. All calls: " + str([c["tools"] for c in estimate_calls]) )