fix(compaction): gate checkpoint replay/prune on current request eligibility

A captured native-compaction checkpoint lives in the persisted
codex_reasoning_items sidecar, but the wire restructure that follows it
(prune_pre_checkpoint_items) ran unconditionally: the native gate only
decided whether context_management went into the request, and no signal
from it ever reached _chat_messages_to_responses_input.

So a single checkpoint kept deleting every pre-checkpoint item from all
later requests — after a mid-session swap out of the gpt-5.6 family,
after compression.enabled: false, after the rejection kill switch, and
after a session resume that reloads the sidecar from state.db. The model
receiving the opaque blob was no longer the one able to decode it, and
nothing was logged.

Thread a single native_compaction_eligible boolean, derived from the same
value that gates the context_management field, into the converter. When
ineligible: do not replay type: "compaction" items and do not prune. Safe
because native compaction never truncates Hermes' local history, so the
fallback still carries the full conversation.

All Responses call sites are covered: build_kwargs and convert_messages
derive the flag via _native_compaction_active, the auxiliary/compression
client is explicitly ineligible, and the converter defaults to False
(pre-feature wire) so future call sites are safe by construction.

Fixes #85914

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
joaomarcos
2026-08-14 04:43:58 -03:00
committed by Teknium
parent cc1c125cf3
commit 11c5aae104
4 changed files with 215 additions and 5 deletions
+8 -1
View File
@@ -1500,8 +1500,15 @@ class _CodexCompletionsAdapter:
# build_kwargs, so they need the same guard applied independently.
_host_for_input = str(getattr(self._client, "base_url", "") or "")
_is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com")
# Auxiliary calls never send ``context_management`` (native
# compaction is a main-turn feature), so they must never replay a
# compaction checkpoint from the replayed history nor let one
# restructure this request — the summarizer/aggregator model is
# usually not even the one that minted the blob.
input_items = _chat_messages_to_responses_input(
replay_messages, is_github_responses=_is_github_for_input,
replay_messages,
is_github_responses=_is_github_for_input,
native_compaction_eligible=False,
)
resp_kwargs: Dict[str, Any] = {
+40 -2
View File
@@ -414,6 +414,7 @@ def _chat_messages_to_responses_input(
is_github_responses: bool = False,
replay_encrypted_reasoning: bool = True,
current_issuer_kind: Optional[str] = None,
native_compaction_eligible: bool = False,
) -> List[Dict[str, Any]]:
"""Convert internal chat-style messages to Responses input items.
@@ -458,6 +459,24 @@ def _chat_messages_to_responses_input(
``replay_encrypted_reasoning=False`` is the session-wide kill switch
(drops ALL replay); ``current_issuer_kind`` is the per-item filter
that runs only when replay is still enabled.
``native_compaction_eligible`` mirrors, for THIS request, the decision
made by ``native_compaction.native_compaction_context_management`` — it
is True only when that gate returned a payload, i.e. when the request
actually carries ``context_management``. It controls two things that
must never outlive the gate: replaying ``type: "compaction"`` checkpoint
items, and restructuring the wire around them
(``prune_pre_checkpoint_items``). Checkpoints are persisted in the
``codex_reasoning_items`` sidecar and survive a mid-session model swap,
a ``compression.enabled: false`` flip, the rejection kill switch and a
resumed session; without this flag a single captured checkpoint would
keep deleting every pre-checkpoint item from every later request, on a
model that cannot decrypt the blob (#85914). Default False = pre-feature
wire, which is also correct for every caller that never sends
``context_management`` (auxiliary/compression client, ad-hoc
``convert_messages``). Dropping the checkpoint costs nothing: Hermes'
local history is never truncated by native compaction, so the full
conversation is still on the wire.
"""
items: List[Dict[str, Any]] = []
seen_item_ids: set = set()
@@ -499,6 +518,20 @@ def _chat_messages_to_responses_input(
item_id = ri.get("id")
if item_id and item_id in seen_item_ids:
continue
# Native-compaction gate: a checkpoint is only
# meaningful to the endpoint/model that minted it
# AND only while this request still asks for
# server-side compaction. Once the gate closes
# (model swapped out of the gpt-5.6 family,
# compression disabled, rejection kill switch),
# the persisted checkpoint must not be replayed —
# replaying it is what makes the wire restructure
# below erase pre-checkpoint history forever.
if (
ri.get("type") == "compaction"
and not native_compaction_eligible
):
continue
# Cross-issuer guard: drop reasoning blocks that
# were minted by a different Responses endpoint.
# The current endpoint cannot decrypt foreign
@@ -697,8 +730,13 @@ def _chat_messages_to_responses_input(
# from before the boundary silently vanish from the model's view. Keep
# the newest checkpoint first, retain pre-checkpoint USER messages
# verbatim within a token budget (Codex CLI parity), and leave the
# post-checkpoint tail untouched. Self-gating: histories without a
# checkpoint (every non-native session) return unchanged.
# post-checkpoint tail untouched. Gated on the CURRENT request's native
# eligibility, not merely on the presence of a checkpoint: a persisted
# checkpoint outlives the gate, and pruning for a request that carries no
# ``context_management`` deletes history the server never compacted.
if not native_compaction_eligible:
return items
from agent.native_compaction import prune_pre_checkpoint_items
return prune_pre_checkpoint_items(items)
+26 -1
View File
@@ -263,6 +263,21 @@ def _is_post_tool_replay(messages: Optional[List[Dict[str, Any]]]) -> bool:
return False
def _native_compaction_active(context_management: Any) -> bool:
"""Is THIS request natively compacted?
True only when the caller's eligibility gate
(``native_compaction.native_compaction_context_management``) produced a
non-empty payload. Every native-compaction side effect on the wire —
sending ``context_management``, replaying a ``type: "compaction"``
checkpoint, restructuring the input around it — hangs off this one
predicate, so a checkpoint that outlives the gate (model swapped out of
the gpt-5.6 family, compression disabled, rejection kill switch, resumed
session) cannot keep reshaping requests on its own.
"""
return isinstance(context_management, list) and bool(context_management)
class ResponsesApiTransport(ProviderTransport):
"""Transport for api_mode='codex_responses'.
@@ -303,6 +318,9 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.get("replay_encrypted_reasoning", True)
),
current_issuer_kind=issuer,
native_compaction_eligible=_native_compaction_active(
kwargs.get("context_management")
),
)
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
@@ -377,6 +395,12 @@ class ResponsesApiTransport(ProviderTransport):
# agent.native_compaction.native_compaction_context_management();
# None means the field is never added to the request.
context_management = params.get("context_management")
# Single source of truth for "this request is natively compacted":
# the same value decides whether the field goes out AND whether the
# converter may replay/prune around a compaction checkpoint. Keeping
# them derived from one expression is what stops a persisted
# checkpoint from restructuring the wire after the gate closes.
native_compaction_active = _native_compaction_active(context_management)
# Resolve the issuing endpoint for this call. Stashed on the
# transport so normalize_response can stamp it onto reasoning
@@ -471,6 +495,7 @@ class ResponsesApiTransport(ProviderTransport):
is_github_responses=is_github_responses,
replay_encrypted_reasoning=replay_encrypted_reasoning,
current_issuer_kind=issuer_kind,
native_compaction_eligible=native_compaction_active,
),
"store": False,
}
@@ -478,7 +503,7 @@ class ResponsesApiTransport(ProviderTransport):
kwargs["tools"] = response_tools
kwargs["tool_choice"] = "auto"
kwargs["parallel_tool_calls"] = True
if isinstance(context_management, list) and context_management:
if native_compaction_active:
kwargs["context_management"] = context_management
session_id = params.get("session_id")
+141 -1
View File
@@ -349,6 +349,7 @@ class TestResponseCapture:
{"role": "user", "content": "next"},
],
current_issuer_kind="codex_backend",
native_compaction_eligible=True,
)
replayed = [item for item in items if item.get("type") == "compaction"]
assert len(replayed) == 1
@@ -375,6 +376,7 @@ class TestResponseCapture:
{"role": "user", "content": "next"},
],
current_issuer_kind="xai_responses",
native_compaction_eligible=True,
)
assert all(item.get("type") != "compaction" for item in items)
@@ -549,7 +551,7 @@ class TestPrunePreCheckpointItems:
},
{"role": "user", "content": "follow-up"},
]
items = _chat_messages_to_responses_input(msgs)
items = _chat_messages_to_responses_input(msgs, native_compaction_eligible=True)
assert items[0] == {"type": "compaction", "encrypted_content": "blob"}
users = [i["content"] for i in items if i.get("role") == "user"]
assert users == ["the goal", "follow-up"]
@@ -569,3 +571,141 @@ class TestPrunePreCheckpointItems:
]
items = _chat_messages_to_responses_input(msgs)
assert [i.get("role") for i in items] == ["user", "assistant", "user"]
class TestCheckpointGatedOnCurrentEligibility:
"""A captured checkpoint must not outlive the native gate.
The checkpoint is persisted in the ``codex_reasoning_items`` sidecar, so
it survives a mid-session model swap, ``compression.enabled: false``, the
rejection kill switch and a resumed session. Every one of those closes the
gate; if the wire kept being restructured around the stale checkpoint,
pre-checkpoint history would be deleted from requests that were never
natively compacted — on a model that cannot even decrypt the blob.
"""
def _history(self):
return [
{"role": "user", "content": "goal: ship the migration"},
{"role": "assistant", "content": "on it"},
{"role": "user", "content": "detail A"},
{
"role": "assistant",
"content": "checkpointed turn",
"codex_reasoning_items": [
{
"type": "compaction",
"encrypted_content": "blob",
"_issuer_kind": "codex_backend",
}
],
},
{"role": "user", "content": "next ask"},
]
def test_ineligible_request_keeps_pre_feature_wire(self):
from agent.codex_responses_adapter import _chat_messages_to_responses_input
history = self._history()
items = _chat_messages_to_responses_input(
history,
current_issuer_kind="codex_backend",
native_compaction_eligible=False,
)
pre_feature = _chat_messages_to_responses_input(
[
{k: v for k, v in msg.items() if k != "codex_reasoning_items"}
for msg in history
],
)
assert items == pre_feature
# Specifically: no checkpoint on the wire, no deleted history.
assert all(i.get("type") != "compaction" for i in items)
assert {"role": "assistant", "content": "on it"} in items
def test_eligible_request_still_restructures(self):
from agent.codex_responses_adapter import _chat_messages_to_responses_input
items = _chat_messages_to_responses_input(
self._history(),
current_issuer_kind="codex_backend",
native_compaction_eligible=True,
)
assert items[0]["type"] == "compaction"
assert {"role": "assistant", "content": "on it"} not in items
def test_converter_defaults_to_ineligible(self):
from agent.codex_responses_adapter import _chat_messages_to_responses_input
items = _chat_messages_to_responses_input(self._history())
assert all(i.get("type") != "compaction" for i in items)
assert {"role": "assistant", "content": "on it"} in items
def test_build_kwargs_without_field_does_not_prune(self):
"""Model swapped out of the gpt-5.6 family / kill switch fired:
the gate returns None, so the wire must be the pre-feature one."""
from agent.transports.codex import ResponsesApiTransport
kwargs = ResponsesApiTransport().build_kwargs(
model="gpt-5.2",
messages=self._history(),
context_management=None,
)
assert "context_management" not in kwargs
assert all(i.get("type") != "compaction" for i in kwargs["input"])
assert {"role": "assistant", "content": "on it"} in kwargs["input"]
def test_build_kwargs_with_field_prunes(self):
from agent.transports.codex import ResponsesApiTransport
kwargs = ResponsesApiTransport().build_kwargs(
model="gpt-5.6",
messages=self._history(),
is_codex_backend=True,
context_management=[{"type": "compaction", "compact_threshold": 4000}],
)
assert kwargs["input"][0]["type"] == "compaction"
assert {"role": "assistant", "content": "on it"} not in kwargs["input"]
def test_convert_messages_defaults_to_ineligible(self):
from agent.transports.codex import ResponsesApiTransport
items = ResponsesApiTransport().convert_messages(
self._history(), is_codex_backend=True
)
assert all(i.get("type") != "compaction" for i in items)
assert {"role": "assistant", "content": "on it"} in items
def test_auxiliary_responses_adapter_never_prunes(self, monkeypatch):
"""Auxiliary calls (compression, flush_memories, MoA) replay real
session history but never send ``context_management`` — so a
checkpoint in that history must not restructure their request."""
import agent.codex_responses_adapter as adapter
from agent.auxiliary_client import _CodexCompletionsAdapter
seen = {}
real = adapter._chat_messages_to_responses_input
def _spy(messages, **kw):
seen.update(kw)
return real(messages, **kw)
monkeypatch.setattr(adapter, "_chat_messages_to_responses_input", _spy)
class _Responses:
def create(self, **kwargs):
seen["input"] = kwargs.get("input")
raise RuntimeError("stop before network")
class _Client:
base_url = "https://chatgpt.com/backend-api/codex"
responses = _Responses()
with pytest.raises(RuntimeError, match="stop before network"):
_CodexCompletionsAdapter(_Client(), "gpt-5.2").create(
messages=self._history()
)
assert seen.get("native_compaction_eligible") is False
assert all(i.get("type") != "compaction" for i in seen["input"])
assert {"role": "assistant", "content": "on it"} in seen["input"]