[verified] fix: account for codex replay in compression tail budget

This commit is contained in:
edgemass 2026-06-13 17:31:00 +09:00 committed by Teknium
parent d4bcd93bb9
commit 78ee0aa367
2 changed files with 145 additions and 0 deletions

View file

@ -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

View file

@ -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."""