Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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


Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"bigstar0920@gmail.com": "bigstar0920",
"285906080+AIalliAI@users.noreply.github.com": "AIalliAI",
"waseemshahwan@users.noreply.github.com": "waseemshahwan",
"hellno@users.noreply.github.com": "hellno",
Expand Down
108 changes: 108 additions & 0 deletions tests/agent/test_context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,114 @@ def test_protect_first_n_decays_when_previous_summary_exists(self):
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."""

Expand Down
Loading