diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a51a65d11bb..45eb25e1ca0 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -298,6 +298,32 @@ def _content_length_for_budget(raw_content: Any) -> int: return total +def _serialized_length_for_budget(value: Any) -> int: + """Return a stable char-length for non-content replay/metadata fields.""" + if value is None or value == "": + return 0 + if isinstance(value, str): + return len(value) + try: + return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)) + except (TypeError, ValueError): + return len(str(value)) + + +# Provider replay/metadata fields that ride the wire on every request but are +# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex +# Responses sessions in particular carry ``codex_reasoning_items`` blobs of +# ``encrypted_content`` that can dominate the serialized session (a measured +# 214-turn session held ~115K tokens / 27% of its payload there — #55572). +_REPLAY_BUDGET_KEYS = ( + "reasoning", + "reasoning_content", + "reasoning_details", + "codex_reasoning_items", + "codex_message_items", +) + + def _estimate_msg_budget_tokens(msg: dict) -> int: """Token estimate for one message in the tail-protection budget walks. @@ -308,12 +334,23 @@ def _estimate_msg_budget_tokens(msg: dict) -> int: 4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected tail overshot ``tail_token_budget`` and compression became ineffective. See issue #28053. + + Also counts provider replay fields (``codex_reasoning_items`` etc. — + see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?" + estimator sees the full message shape, so the tail walk must use the + same size class; otherwise an assistant message with tiny visible + content but large hidden replay blobs is protected as if it were small, + the post-compression session stays near the context limit, and + compaction re-fires continuously (#55572). Accounting-only: replay + fields are never mutated or pruned here. """ content_len = _content_length_for_budget(msg.get("content") or "") tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead for tc in msg.get("tool_calls") or []: if isinstance(tc, dict): tokens += len(str(tc)) // _CHARS_PER_TOKEN + for key in _REPLAY_BUDGET_KEYS: + tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN return tokens diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 231f8b4077f..75b22bb8a80 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -407,6 +407,114 @@ class TestCompress: assert c._effective_protect_first_n() == 0 +class TestTailBudgetCodexReplayFields: + def test_tail_cut_counts_codex_replay_and_reasoning_fields(self): + """Tail protection must budget hidden replay fields sent back to providers. + + Codex Responses messages can have tiny visible content but large + `codex_reasoning_items`, `codex_message_items`, or provider-native + reasoning fields. Preflight compression counts these fields, so the + tail-cut budget must count them too; otherwise compression preserves an + oversized tail and immediately starts the next session near the limit. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + big_replay = "x" * 5_000 + big_hidden_message = { + "role": "assistant", + "content": "ok", + "reasoning": "summary " + big_replay, + "reasoning_content": "scratchpad " + big_replay, + "reasoning_details": [{"text": "details " + big_replay}], + "codex_reasoning_items": [ + {"type": "reasoning", "encrypted_content": "enc_" + big_replay} + ], + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "reply " + big_replay}], + } + ], + } + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "initial ask"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "older follow-up"}, + big_hidden_message, + ] + messages.extend( + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"tail visible message {i}", + } + for i in range(14) + ) + + cut_idx = c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) + + assert cut_idx == 5 + assert messages[4]["codex_reasoning_items"][0]["encrypted_content"].startswith("enc_") + assert messages[4]["codex_message_items"][0]["content"][0]["text"].startswith("reply ") + + @pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("reasoning", "x" * 5_000), + ("reasoning_content", "x" * 5_000), + ("reasoning_details", [{"text": "x" * 5_000}]), + ( + "codex_reasoning_items", + [{"type": "reasoning", "encrypted_content": "x" * 5_000}], + ), + ( + "codex_message_items", + [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "x" * 5_000}], + } + ], + ), + ], + ) + def test_tail_cut_counts_each_hidden_replay_field(self, field_name, field_value): + """Each provider replay/reasoning field should affect tail budgeting.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + hidden_message = {"role": "assistant", "content": "ok", field_name: field_value} + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "initial ask"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "older follow-up"}, + hidden_message, + ] + messages.extend( + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"tail visible message {i}", + } + for i in range(14) + ) + + assert c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) == 5 + + class TestGenerateSummaryNoneContent: """Regression: content=None (from tool-call-only assistant messages) must not crash."""