mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
[verified] fix: account for codex replay in compression tail budget
This commit is contained in:
parent
d4bcd93bb9
commit
78ee0aa367
2 changed files with 145 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue