Skip to content

fix(native_compaction): preserve compression summary messages during pre-checkpoint pruning - #90976

Closed
JoaoMarcos44 wants to merge 7 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/native-compaction-summary-retention
Closed

fix(native_compaction): preserve compression summary messages during pre-checkpoint pruning#90976
JoaoMarcos44 wants to merge 7 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/native-compaction-summary-retention

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Preserves local-compression summary messages across native OpenAI Responses compaction's pre-checkpoint pruning (agent/native_compaction.py), so a compression handoff never silently vanishes from the model's view once a server-side checkpoint has been replayed.

Fixes #90975.


Technical details

1. Canonical summary detection, not a heuristic

_is_summary_item delegates entirely to agent.context_compressor.is_compaction_summary_message — the same provenance check every other summary consumer (memory providers, frontends, the compactor itself) already uses. It prefers the exact COMPRESSED_SUMMARY_METADATA_KEY flag and falls back to the canonical prefix classifier (handles the merge-into-tail shape too) for the case where the underscore-prefixed key was stripped by a wire sanitizer. No ad-hoc key scanning, no matching on content headings like "## Summary" in ordinary text.

2. Whole-or-drop retention, never a byte slice

Retained summaries carry structural framing (handoff prefix, end marker, merge-into-tail delimiters) that a blind character slice can corrupt. A summary that doesn't fit RETAINED_SUMMARY_TOKEN_BUDGET (32k tokens) is dropped whole instead of truncated. RETAINED_USER_MESSAGE_TOKEN_BUDGET (64k tokens, Codex CLI parity) keeps its existing head-truncation behavior for retained user messages only.

3. Idempotent across repeated checkpoints

A summary already retained once (identical text) is never duplicated across repeated checkpoint sequences.

4. Source-based recovery for lossy carrier shapes

A canonical summary can be merged into an existing tail message rather than inserted standalone (ContextCompressor merge-into-tail). By the time that message becomes a Responses input item, two real shapes lose or shadow the summary before pruning ever runs:

  • a tool-result carrier becomes a typed function_call_output — no content/role survives the conversion at all, and the pruner's type filter skips every non-message item;
  • an assistant carrier can be shadowed by a stale codex_message_items exact-replay captured before the merge rewrote its content — the replay path wins over the rewritten content for prefix-cache continuity.

_chat_messages_to_responses_input now threads item_sources — the raw chat message each converted item came from — through to prune_pre_checkpoint_items. When a source is itself a canonical summary carrier, its content is read directly from the source (never from the lossy converted item) and retained as a synthesized role="assistant" message. This never orphans a function_call_output/function_call pair: the original typed item is fully replaced, not retained alongside a dropped partner.

5. enable_summary_retention

Function-level override for tests and callers that need pre-#90975 behavior back. Not wired to a user-facing config surface — there is no agent reference at the actual call site (codex_responses_adapter.py) to wire real config without much broader plumbing.


Test plan

  • tests/run_agent/test_native_compaction_summary_retention.py — canonical detection, negative witnesses (a "## Summary" heading in ordinary text, a False-valued flag, an arbitrary underscore key never misclassify), whole-or-drop truncation, idempotency, live ContextCompressor marker emissions, and two new adapter-level witnesses feeding real merge-into-tail carrier shapes (tool-result, assistant-with-stale-codex_message_items) through the real _chat_messages_to_responses_input(..., native_compaction_eligible=True) with a replayed checkpoint.
  • tests/run_agent/test_native_compaction.py, tests/agent/test_compressed_summary_metadata.py, tests/agent/test_codex_responses_adapter.py, tests/run_agent/test_run_agent_codex_responses.py, tests/run_agent/test_provider_parity.py, tests/run_agent/test_codex_multimodal_tool_result.py — no regressions (117 passed).
  • ruff check clean on all changed files.

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#00f0ff', 'mainBkg': '#0a0a16', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#ff007f', 'lineColor': '#00f0ff'}}}%%
graph TD
    A[Compressor Merge-Into-Tail] -->|Stamps COMPRESSED_SUMMARY_METADATA_KEY| B[Chat Message Source]
    B -->|role=tool| C[Lossy: function_call_output]
    B -->|role=assistant + stale codex_message_items| D[Lossy: Stale Exact Replay]
    B -->|item_sources mapping| E[Pruner Reads Source Directly]
    C -.->|old bug: type filter skips it| F[Summary Lost]
    D -.->|old bug: replay shadows rewrite| F
    E -->|Canonical Content, Whole-or-Drop| G[Synthesized Assistant Message]
    G --> H[Checkpoint Run + Retained + Post]
    H --> I[Wire: Summary Survives Exactly Once]
Loading

Infographic:

infographic_90976_oriental

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/openai OpenAI / Codex Responses API area/compression Context compression and continuation sessions P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 20, 2026
…nd sequence ordering (NousResearch#90976)

- Add dedicated RETAINED_SUMMARY_TOKEN_BUDGET (32,000 tokens) with head-truncation fallback
- Implement robust multi-pattern _is_summary_item helper inspecting top-level flags, nested metadata dicts, and string headers
- Implement safe _extract_item_text for string, multipart lists, output_text, and nested text
- Preserve original relative chronological order between retained user messages and summaries
- Add logger audit metrics and enable_summary_retention configuration toggle
- Add comprehensive test matrix covering multipart inputs, budget limits, malformed payloads, and toggle controls

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking review — do not merge current head d85dffa0db84aabba76b6daa69e04b6674547eeb.

The bug is real, but this patch replaces missing retention with an untyped persistence channel across the native compaction boundary.

Hermes already has an authoritative summary producer contract in agent.context_compressor: COMPRESSED_SUMMARY_METADATA_KEY, ContextCompressor.classify_summary_content(...), and is_compaction_summary_message(...), with live-emission agreement tests. _is_summary_item() ignores that contract and independently classifies content by broad heuristics:

  • any top-level underscore-prefixed key containing summary, regardless of its value;
  • any metadata key containing summary or compression, also regardless of value;
  • arbitrary message text containing phrases such as conversation summary, [summary], or ## summary.

That means a normal user/assistant message, a false-valued marker such as {"_summary_requested": false}, or untrusted content containing a summary heading can be promoted to durable retained history. A user message matching the heuristic is also charged to the separate summary budget instead of the user budget. This is authority drift and can preserve stale or adversarial instructions across a checkpoint.

There is a second structural problem at the budget boundary: generic head truncation of a compressed-summary message is not shape-safe. Hermes summaries can be standalone or merged into a preserved tail with explicit delimiters. Slicing content[:remaining * 4] can retain the old tail while cutting off the actual summary, or break the framing/closing marker that keeps historical text non-active.

Required before merge:

  1. Reuse the canonical summary predicate/metadata constant rather than adding a second heuristic detector. Exact metadata provenance should win; content fallback should be only the canonical persisted-row classifier already maintained by the compressor.
  2. Require truthy exact markers—never infer authority from a key name alone, and never classify arbitrary headings in ordinary content.
  3. Preserve canonical summary shapes whole, or add a structure-aware extraction/truncation path that understands standalone versus merged emissions. Do not byte/character-slice the opaque message envelope.
  4. Add negative witnesses for ## Summary in ordinary user text, false-valued summary/compression metadata, arbitrary underscore keys, and non-Hermes assistant content.
  5. Add live-compressor emission tests for both standalone and merged summaries at the retention boundary, plus repeated-checkpoint/idempotency coverage. The PR body claims deduplication, but the implementation currently retains every matching pre-checkpoint item.
  6. Either wire enable_summary_retention to a real configuration surface or remove the claim that this adds a configuration toggle; at present it is only a function parameter.

Exact-head CI/Nix/Docker are green, but the tests encode the permissive detector as expected behavior and therefore do not prove summary provenance or safe retention.

Delegates summary detection to the canonical
agent.context_compressor.is_compaction_summary_message provenance check
instead of an ad-hoc key/content heuristic, adds negative-witness
coverage so lookalike content is never promoted to retained history,
switches oversized summaries to whole-or-drop instead of byte-slicing
(which could corrupt structural framing), makes retention idempotent
across repeated checkpoints, adds tests against live
ContextCompressor emissions, and corrects the enable_summary_retention
docstring to stop overclaiming it as a wired user-facing config toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Addressed all items from the blocking review:

  1. Canonical detection_is_summary_item now delegates entirely to agent.context_compressor.is_compaction_summary_message (exact COMPRESSED_SUMMARY_METADATA_KEY flag, falling back to the canonical prefix classifier) instead of an ad-hoc underscore-key/content-substring heuristic. Confirmed importing agent.context_compressor here does not introduce a cycle — it sits below native_compaction in the dependency graph.
  2. Negative witnesses — added tests proving a "## Summary" heading in ordinary user text, a False-valued metadata flag, and an arbitrary _..._summary_... key are never misclassified as a retained summary.
  3. Whole-or-drop truncation — an oversized summary is now dropped entirely rather than byte-sliced, since slicing can corrupt the structural framing (SUMMARY_PREFIX / _SUMMARY_END_MARKER / merge headers) that keeps it recognizable as inactive history.
  4. Idempotency — retention now dedups by summary text across repeated checkpoint sequences, so the same summary is never retained twice.
  5. Live-emission coverage — added tests against ContextCompressor._render_micro_marker_content output directly (both standalone and merged-tail shapes), not just hand-built fixtures.
  6. enable_summary_retention docstring — corrected to state it's a function-level override, not a wired user-facing config toggle (no agent reference exists at the actual call site in codex_responses_adapter.py to wire real config without much broader plumbing).

Validated: ruff check clean; 22 new tests pass; existing test_native_compaction.py (52) and test_compressed_summary_metadata.py + test_codex_responses_adapter.py (25) suites pass with no regressions.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking re-review — do not merge exact head bf8703c262fa6c945e9ab6d40ff893c4f327715d.

The canonical predicate, truthy provenance, whole-or-drop handling, negative witnesses, and dedup/idempotency blockers from the first review are resolved. Exact-head CI, Nix, and Docker are green.

One blocking runtime path remains: summary provenance is checked only after _chat_messages_to_responses_input() has already erased or displaced it.

ContextCompressor.compress() explicitly allows merge-into-tail to use the first protected tail row when that row is template-exempt; its own comment names a bare assistant tool-call row or a tool result as the ideal carrier. It rewrites that chat message's content and stamps COMPRESSED_SUMMARY_METADATA_KEY.

Production then converts the chat transcript before prune_pre_checkpoint_items() runs:

  • A merged tool-result carrier becomes {"type": "function_call_output", "output": ...}. The private summary marker is not forwarded, _extract_item_text() does not read output, and the pruner skips every typed non-message item before calling _is_summary_item(). That canonical summary is still dropped at the checkpoint.
  • A merged assistant carrier with codex_message_items can also lose the rewrite. The adapter replays the exact stored message item and, once replayed_message_items > 0, deliberately does not emit the assistant row's rewritten content. drop_stale_api_content() clears only api_content, and the compressor's stale-replay pass strips only codex_reasoning_items, so the pre-merge exact-message sidecar can shadow the summary before the pruner sees it.

The new tests do not cross this boundary. test_standalone_live_marker_is_retained calls only _render_micro_marker_content; test_merged_tail_summary_is_retained_and_classified_merged manually assembles private delimiter constants and passes a synthetic role="assistant" dict directly to the pruner. The linked reply's claim that both shapes are live compressor emissions is therefore incorrect, and neither test exercises the Responses adapter where the loss occurs.

Required before merge:

  1. Add end-to-end witnesses using a real ContextCompressor.compress() emission, then feed its output through _chat_messages_to_responses_input(..., native_compaction_eligible=True) with a replayed checkpoint. Cover standalone plus merge-into-tail on at least a tool-result carrier and an assistant carrier carrying codex_message_items.
  2. Preserve canonical summary provenance/content before the lossy role/tool conversion, or map a canonical carrier into a dedicated valid Responses message before pruning. Do not fix this by heuristically treating arbitrary function_call_output.output as summaries.
  3. Assert the fix emits neither an orphaned function_call_output nor stale codex_message_items, and that the canonical summary appears after the newest checkpoint exactly once.
  4. Update the PR body and the adapter's user-only pruning comment. The body still documents the removed broad detector, summary head-slicing, and a user-facing toggle that does not exist.

No need to revisit the first review's items 1–4 or the text-dedup work; those are fixed. This remaining blocker is the actual producer → adapter → pruner contract.

…ousResearch#90976)

The pruner only ever saw whatever survived _chat_messages_to_responses_input's
conversion. Two merge-into-tail carrier shapes lose the summary before pruning
ever runs: a tool-result carrier becomes a typed function_call_output (no
content/role survives), and an assistant carrier can be shadowed by a stale
codex_message_items exact-replay captured before the merge rewrote its
content. Thread the original chat message alongside each converted item
(item_sources) so prune_pre_checkpoint_items can read a canonical summary
carrier's up-to-date content straight from its source instead of trying to
recover it from whatever shape conversion produced.
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Addressed the remaining blocking item (runtime path: summary provenance checked after _chat_messages_to_responses_input() already erased/displaced it):

  1. Preserve provenance before the lossy conversion. _chat_messages_to_responses_input now threads item_sources — the raw chat message each converted Responses item came from — alongside items, all the way to prune_pre_checkpoint_items.
  2. Source-based retention. When a pre-checkpoint item's source is itself a canonical summary carrier (is_compaction_summary_message), its content is read directly from the source — never from the lossy converted item — and retained as a synthesized role="assistant" message. This covers both flagged shapes: a tool-result carrier (typed function_call_output, no content/role at all) and an assistant carrier shadowed by a stale codex_message_items exact-replay from before the merge rewrote its content.
  3. No orphaning. The original typed item (function_call_output or the stale replayed message) is fully replaced in the retained set, never kept alongside a dropped function_call partner — the pruner already drops every function_call/other typed non-message item from the pre-checkpoint segment regardless, so nothing pairs with a partial output anymore.
  4. New adapter-level witnesses, not hand-built pruner fixtures: test_tool_result_merge_carrier_summary_survives_the_adapter and test_assistant_merge_carrier_with_stale_replay_summary_survives build the exact chat-message shapes ContextCompressor merge-into-tail produces (same metadata stamp, same merge delimiters) and feed them through the real _chat_messages_to_responses_input(..., native_compaction_eligible=True) with a replayed checkpoint — both fail on pre-fix code and pass now.
  5. PR body and adapter comment updated to describe the current implementation (the body previously still documented the removed broad detector, summary head-slicing, and a non-existent user-facing toggle).

Backward compatible: item_sources is optional and defaults to None, so every existing caller/test that calls prune_pre_checkpoint_items(items) directly is unaffected.

Validated: ruff check clean; 117 tests pass across test_native_compaction_summary_retention.py, test_native_compaction.py, test_compressed_summary_metadata.py, test_codex_responses_adapter.py, test_run_agent_codex_responses.py, test_provider_parity.py, test_codex_multimodal_tool_result.py — no regressions.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at exact head 99937f26c7c8f923c8acf620e19731b58e2e5de0.

The runtime repair itself now closes the conversion-order defect from my prior review: item_sources is carried in parallel with every emitted Responses item; canonical provenance is checked on the raw source before lossy tool/replay conversion can erase it; a qualifying source is synthesized back as one assistant message; the pre-checkpoint typed call/output material is not retained, so this does not leave an orphaned function_call_output; and the stale codex_message_items replay is displaced by the current source content. The PR body/commentary now describes that actual path. Exact-head CI 32430608964, Docker 32430608304, and Nix 32430608294 are all green.

One proof blocker from the prior review is still not satisfied, though. I asked for end-to-end witnesses that use a real ContextCompressor.compress() emission and then feed that mutated transcript through _chat_messages_to_responses_input(..., native_compaction_eligible=True). The two new adapter tests still manufacture the producer state themselves with _merged_summary_content(...) plus COMPRESSED_SUMMARY_METADATA_KEY = True. Their own class docstring says they are merely “shaped exactly the way” the compressor produces them. That proves the adapter fix for the assumed shape, but it does not prove the producer→adapter contract or catch future/current drift in the compressor's actual carrier selection/mutation semantics.

Please replace or supplement those fixtures with producer-backed witnesses: drive ContextCompressor.compress() far enough to produce (a) the tool-result merge carrier and (b) the assistant carrier with the replay sidecar, then pass those actual resulting messages through the adapter and retain the existing assertions: newest checkpoint first, summary exactly once, no stale replay text, and no orphaned typed output. A standalone real-compressor case is useful too, but the two lossy merge carriers are the load-bearing proof.

Disposition: implementation looks correct; merge remains blocked only on the missing producer-backed witness. No need to revisit the already-closed provenance, budget, dedup, or documentation items.

@jackulau

Copy link
Copy Markdown
Contributor

I came at #90975 independently, reproduced it on fc9cbc872d, and then found this PR already open, so I put the time into reviewing yours instead. The core diagnosis is right and the retention path works. Two things below are behavioral regressions I reproduced against main, both currently uncovered by the 101 tests in this PR's suites, and both closable in one line each. Everything here is measured on your head 99937f26c7.

Worth saying up front: item_sources is the right idea. The two lossy carrier shapes you describe are real, and reading provenance from the source message rather than from the converted item is the correct instinct. The problem is that the source path is applied unconditionally rather than as a fallback, and it takes the common case down with it.

1. A role="user" summary is silently rewritten to role="assistant"

native_compaction.py:361 hard-codes the synthesized role:

retained_reversed.append({"role": "assistant", "content": text})

That branch fires for every source that passes _is_summary_item, not only for the two lossy shapes it was written for. The compressor's most common standalone output is summary_role = "user" (context_compressor.py:7698-7703: chosen whenever compress_start == 0, or the head ends assistant/tool, or the head is all-exempt, because "strict templates require to start with user"). That message converts to a perfectly intact Responses item — nothing is lossy about it — and the source path overrides it anyway.

Same input, with and without item_sources:

WITH item_sources:
   compaction
   role=assistant  "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier..."     <-- flipped
   role=user       "now add tests"
WITHOUT item_sources (what you call pre-#90976 behavior):
   compaction
   role=user       "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier..."     <-- correct
   role=user       "now add tests"

Note what that second block shows: for the standalone case the item_sources machinery adds nothing except the flip. _is_summary_item(item) already catches it through the prefix classifier, so the summary was being retained correctly on the item path alone.

The flip is not cosmetic, and this repo has already paid for both sides of it. context_compressor.py:334-336:

# Without it, weak models read the verbatim "## Active Task" quote as fresh
# user input (#11475, #14521) or regurgitate an assistant-role summary as
# their own output (#33256).

Those are two different named failure modes selected by role, and _build_compressed_messages picks between them deliberately per-conversation. Rewriting every user-role summary to assistant at the wire opts the whole population into #33256 as a side effect of a pruning fix. _splice_micro_compact_result records the same lesson from the other direction: a role change there produced "metadata gone, cursor unrecoverable, and the summary text duplicated".

Secondary, but in the same area: the role is also load-bearing for alternation. Lines 7706-7718 flip summary_role, and fall back to merge-into-tail when neither role works, specifically to avoid consecutive same-role messages. Overriding the result downstream discards that computation.

Minimal fix — preserve the source's own role, falling back to assistant only when there isn't one:

_src_role = source.get("role")
retained_reversed.append({
    "role": _src_role if _src_role in ("user", "assistant") else "assistant",
    "content": text,
})

This keeps your fix fully intact for both target shapes: a merge-into-tail tool-result carrier is role="tool", so it still synthesizes assistant; a stale-replay carrier is already role="assistant". I applied exactly this and your two adapter-level witness tests still pass, which is the proof that it doesn't weaken the case you built it for.

The structurally better version is to make the source path a genuine fallback: retain item verbatim when _is_summary_item(item) already holds and _extract_item_text(item) is non-empty, and reach for source only when the converted item lost the content. That is a bigger edit; the one-liner above is enough to close the regression.

2. Image-only user messages are dropped, where main retains them

main's _user_item_text returns "" (not None) for a user item whose content is a list carrying only images — that is the entire purpose of its trailing or content clause, and the caller tested if text is None: continue, so the item was retained at _approx_tokens("") == 1 token. Your _extract_item_text preserves the or content clause and still returns "", but the caller at line 379 now reads:

if not text:
    continue

"" is falsy, so the item is skipped. The distinction the helper still computes is discarded one line later, which makes that or content clause dead code.

Measured on both trees, identical input:

BASELINE (fc9cbc872d)  image-only user msg retained? True
PR HEAD  (99937f26c7)  image-only user msg retained? False

A screenshot pasted with no caption is exactly the message most likely to have no text, and this is the same class of silent history loss #90975 is about, reintroduced in a neighboring lane. main counts images as zero tokens on purpose ("matching Codex's retention accounting"), so the cost of keeping them is one token each.

Minimal fix:

text = _extract_item_text(item)
if text is None or (is_summary and not text.strip()):
    continue

Both fixes together, applied to your head: both regressions close and tests/run_agent/test_native_compaction_summary_retention.py, tests/run_agent/test_native_compaction.py, tests/agent/test_compressed_summary_metadata.py and tests/agent/test_codex_responses_adapter.py still give 101 passed. That the suite is green both before and after is the finding worth acting on independently of the fixes: neither behavior has a test. input_image appears nowhere in either pruner test file, and no test asserts on the retained summary's role.

3. A future unpaired append silently disables the whole fix

Line 331 guards the desync correctly:

if isinstance(item_sources, list) and len(item_sources) == len(items):
    pre_sources = item_sources[:first_cp]
else:
    pre_sources = [None] * len(pre)

Falling back rather than misattributing is the right call. But the two lists are maintained by hand across nine items.append sites in a ~300-line function, and the failure mode of missing one is that this fix quietly stops working with no signal at all, in the one case (function_call_output carriers) where the item path cannot recover. I checked all nine on your head and they are correctly paired today; the risk is entirely about the next person editing that function.

Cheapest durable version is a local closure in the adapter so the pairing can't be forgotten:

def _emit(item: Dict[str, Any], source: Optional[Dict[str, Any]]) -> None:
    items.append(item)
    item_sources.append(source)

Failing that, a logger.warning in the else branch above would at least make the degradation observable, since it currently looks identical to "no sources were passed".

4. Summaries add a second budget on top of the user budget

RETAINED_SUMMARY_TOKEN_BUDGET = 32_000 is tracked in summary_remaining, independent of user_remaining = 64_000. So the retained block after a checkpoint can now reach 96k tokens where it was capped at 64k. Given that half of this function's stated purpose is that pre-checkpoint history is "dead upload weight", a 50% increase in the retained ceiling reads to me as a maintainer decision rather than an implementation detail, and I'd put the number and its rationale in the PR description explicitly. It may well be the right call given the alternative is losing the summary; I just don't think it should land implicitly.

5. Two comment losses that are worth restoring

The RETAINED_USER_MESSAGE_TOKEN_BUDGET comment now ends mid-sentence:

# Live verification (Aug 2026, gpt-5.6 @ api.openai.com): the server renders
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000

The eight lines carrying the actual evidence ("NONE" recall before the checkpoint versus perfect recall after) were removed. That is the only record of that experiment in the tree, and it is the justification for the constant sitting under it.

The prune_pre_checkpoint_items docstring also dropped the function_call / function_call_output pairing invariant and the determinism note ("so the request prefix stays stable across turns and server-side prompt caching keeps working"). The determinism property does still hold after your change, as far as I can tell, since seen_summary_texts is only ever membership-tested. But it is an invariant this PR moves right up against, so I would restate it rather than drop it.

6. On "not a heuristic"

Small framing point on the PR description. is_compaction_summary_message prefers the metadata flag but falls back to _is_context_summary_content, which is a content-prefix match. The description says the detection is canonical rather than heuristic, and that is fair relative to scanning for "## Summary", but the fallback is still content matching on a string a user can paste verbatim.

That matters more than usual here, because COMPRESSED_SUMMARY_METADATA_KEY never survives into a converted item at all: every items.append in _chat_messages_to_responses_input builds a fresh dict with only role and content, with no **msg spread anywhere. So on the item path the metadata branch is unreachable by construction, not merely "stripped by a wire sanitizer sometimes", and detection there rests entirely on the prefix. Worth stating plainly in the description so nobody later assumes the flag is doing the work.

(For whatever it is worth to the reporter: of the three metadata keys #90975 names, only _compressed_summary exists in the tree. _is_compression_summary and _hermes_compressed_summary have zero occurrences on main. Anyone reading the issue after this merges should not go looking for them.)


None of this is a blocker from my side. #1 and #2 are the two I would want closed before merge, and both are one line. Happy to send them as a patch to your branch along with the two missing tests if that is easier than churning the PR.

@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

I came at #90975 independently, reproduced it on fc9cbc872d, and then found this PR already open, so I put the time into reviewing yours instead. The core diagnosis is right and the retention path works. Two things below are behavioral regressions I reproduced against main, both currently uncovered by the 101 tests in this PR's suites, and both closable in one line each. Everything here is measured on your head 99937f26c7.

Worth saying up front: item_sources is the right idea. The two lossy carrier shapes you describe are real, and reading provenance from the source message rather than from the converted item is the correct instinct. The problem is that the source path is applied unconditionally rather than as a fallback, and it takes the common case down with it.

1. A role="user" summary is silently rewritten to role="assistant"

native_compaction.py:361 hard-codes the synthesized role:

retained_reversed.append({"role": "assistant", "content": text})

That branch fires for every source that passes _is_summary_item, not only for the two lossy shapes it was written for. The compressor's most common standalone output is summary_role = "user" (context_compressor.py:7698-7703: chosen whenever compress_start == 0, or the head ends assistant/tool, or the head is all-exempt, because "strict templates require to start with user"). That message converts to a perfectly intact Responses item — nothing is lossy about it — and the source path overrides it anyway.

Same input, with and without item_sources:

WITH item_sources:
   compaction
   role=assistant  "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier..."     <-- flipped
   role=user       "now add tests"
WITHOUT item_sources (what you call pre-#90976 behavior):
   compaction
   role=user       "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier..."     <-- correct
   role=user       "now add tests"

Note what that second block shows: for the standalone case the item_sources machinery adds nothing except the flip. _is_summary_item(item) already catches it through the prefix classifier, so the summary was being retained correctly on the item path alone.

The flip is not cosmetic, and this repo has already paid for both sides of it. context_compressor.py:334-336:

# Without it, weak models read the verbatim "## Active Task" quote as fresh
# user input (#11475, #14521) or regurgitate an assistant-role summary as
# their own output (#33256).

Those are two different named failure modes selected by role, and _build_compressed_messages picks between them deliberately per-conversation. Rewriting every user-role summary to assistant at the wire opts the whole population into #33256 as a side effect of a pruning fix. _splice_micro_compact_result records the same lesson from the other direction: a role change there produced "metadata gone, cursor unrecoverable, and the summary text duplicated".

Secondary, but in the same area: the role is also load-bearing for alternation. Lines 7706-7718 flip summary_role, and fall back to merge-into-tail when neither role works, specifically to avoid consecutive same-role messages. Overriding the result downstream discards that computation.

Minimal fix — preserve the source's own role, falling back to assistant only when there isn't one:

_src_role = source.get("role")
retained_reversed.append({
    "role": _src_role if _src_role in ("user", "assistant") else "assistant",
    "content": text,
})

This keeps your fix fully intact for both target shapes: a merge-into-tail tool-result carrier is role="tool", so it still synthesizes assistant; a stale-replay carrier is already role="assistant". I applied exactly this and your two adapter-level witness tests still pass, which is the proof that it doesn't weaken the case you built it for.

The structurally better version is to make the source path a genuine fallback: retain item verbatim when _is_summary_item(item) already holds and _extract_item_text(item) is non-empty, and reach for source only when the converted item lost the content. That is a bigger edit; the one-liner above is enough to close the regression.

2. Image-only user messages are dropped, where main retains them

main's _user_item_text returns "" (not None) for a user item whose content is a list carrying only images — that is the entire purpose of its trailing or content clause, and the caller tested if text is None: continue, so the item was retained at _approx_tokens("") == 1 token. Your _extract_item_text preserves the or content clause and still returns "", but the caller at line 379 now reads:

if not text:
    continue

"" is falsy, so the item is skipped. The distinction the helper still computes is discarded one line later, which makes that or content clause dead code.

Measured on both trees, identical input:

BASELINE (fc9cbc872d)  image-only user msg retained? True
PR HEAD  (99937f26c7)  image-only user msg retained? False

A screenshot pasted with no caption is exactly the message most likely to have no text, and this is the same class of silent history loss #90975 is about, reintroduced in a neighboring lane. main counts images as zero tokens on purpose ("matching Codex's retention accounting"), so the cost of keeping them is one token each.

Minimal fix:

text = _extract_item_text(item)
if text is None or (is_summary and not text.strip()):
    continue

Both fixes together, applied to your head: both regressions close and tests/run_agent/test_native_compaction_summary_retention.py, tests/run_agent/test_native_compaction.py, tests/agent/test_compressed_summary_metadata.py and tests/agent/test_codex_responses_adapter.py still give 101 passed. That the suite is green both before and after is the finding worth acting on independently of the fixes: neither behavior has a test. input_image appears nowhere in either pruner test file, and no test asserts on the retained summary's role.

3. A future unpaired append silently disables the whole fix

Line 331 guards the desync correctly:

if isinstance(item_sources, list) and len(item_sources) == len(items):
    pre_sources = item_sources[:first_cp]
else:
    pre_sources = [None] * len(pre)

Falling back rather than misattributing is the right call. But the two lists are maintained by hand across nine items.append sites in a ~300-line function, and the failure mode of missing one is that this fix quietly stops working with no signal at all, in the one case (function_call_output carriers) where the item path cannot recover. I checked all nine on your head and they are correctly paired today; the risk is entirely about the next person editing that function.

Cheapest durable version is a local closure in the adapter so the pairing can't be forgotten:

def _emit(item: Dict[str, Any], source: Optional[Dict[str, Any]]) -> None:
    items.append(item)
    item_sources.append(source)

Failing that, a logger.warning in the else branch above would at least make the degradation observable, since it currently looks identical to "no sources were passed".

4. Summaries add a second budget on top of the user budget

RETAINED_SUMMARY_TOKEN_BUDGET = 32_000 is tracked in summary_remaining, independent of user_remaining = 64_000. So the retained block after a checkpoint can now reach 96k tokens where it was capped at 64k. Given that half of this function's stated purpose is that pre-checkpoint history is "dead upload weight", a 50% increase in the retained ceiling reads to me as a maintainer decision rather than an implementation detail, and I'd put the number and its rationale in the PR description explicitly. It may well be the right call given the alternative is losing the summary; I just don't think it should land implicitly.

5. Two comment losses that are worth restoring

The RETAINED_USER_MESSAGE_TOKEN_BUDGET comment now ends mid-sentence:

# Live verification (Aug 2026, gpt-5.6 @ api.openai.com): the server renders
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000

The eight lines carrying the actual evidence ("NONE" recall before the checkpoint versus perfect recall after) were removed. That is the only record of that experiment in the tree, and it is the justification for the constant sitting under it.

The prune_pre_checkpoint_items docstring also dropped the function_call / function_call_output pairing invariant and the determinism note ("so the request prefix stays stable across turns and server-side prompt caching keeps working"). The determinism property does still hold after your change, as far as I can tell, since seen_summary_texts is only ever membership-tested. But it is an invariant this PR moves right up against, so I would restate it rather than drop it.

6. On "not a heuristic"

Small framing point on the PR description. is_compaction_summary_message prefers the metadata flag but falls back to _is_context_summary_content, which is a content-prefix match. The description says the detection is canonical rather than heuristic, and that is fair relative to scanning for "## Summary", but the fallback is still content matching on a string a user can paste verbatim.

That matters more than usual here, because COMPRESSED_SUMMARY_METADATA_KEY never survives into a converted item at all: every items.append in _chat_messages_to_responses_input builds a fresh dict with only role and content, with no **msg spread anywhere. So on the item path the metadata branch is unreachable by construction, not merely "stripped by a wire sanitizer sometimes", and detection there rests entirely on the prefix. Worth stating plainly in the description so nobody later assumes the flag is doing the work.

(For whatever it is worth to the reporter: of the three metadata keys #90975 names, only _compressed_summary exists in the tree. _is_compression_summary and _hermes_compressed_summary have zero occurrences on main. Anyone reading the issue after this merges should not go looking for them.)


None of this is a blocker from my side. #1 and #2 are the two I would want closed before merge, and both are one line. Happy to send them as a patch to your branch along with the two missing tests if that is easier than churning the PR.

Of course, you can do this. When I get home, I'll save your commit to my PR. And if I can't do it in time before the maintainers do, they will. I appreciate this feedback again, which I wasn't able to see.

kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Aug 21, 2026
…pre-checkpoint pruning

prune_pre_checkpoint_items() had a hardcoded role=='user' filter that
discarded all non-user messages before a checkpoint — including Hermes'
own compression summaries (role='assistant'), causing total context amnesia
about past conversation summaries.

The fix:
- _is_summary_item delegates to the canonical
  agent.context_compressor.is_compaction_summary_message provenance check
  (not an ad-hoc heuristic)
- Summaries are retained whole (never byte-sliced) within a 32k token budget
- Idempotent across repeated checkpoints (dedup by identical text)
- _chat_messages_to_responses_input threads item_sources (raw chat messages)
  through to the pruner, so it can read summary content directly from the
  source when the Responses conversion shape is lossy (tool-result carrier
  becomes function_call_output, or stale codex_message_items replay shadows
  merged content)

Fixes NousResearch#90975.

Salvage of NousResearch#90976 by @JoaoMarcos44.
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #91477 — your commits applied with authorship preserved (rebase-merge).

Thank you for this fix! The approach is exactly right: canonical provenance check via is_compaction_summary_message (not an ad-hoc heuristic), whole-or-drop retention (never byte-slice structural framing), idempotent dedup, and the item_sources threading to recover summaries from lossy Responses conversion shapes (tool-result carrier → function_call_output, stale codex_message_items replay). The 24 tests are thorough — covering retention, budget, idempotency, live compressor emissions, carrier-loss adapter path, and malformed inputs.

Follow-up cleanups we applied on top during /simplify-code review:

  1. Extracted _try_retain_summary helper to eliminate copy-pasted retention logic between source-based and item-based paths
  2. Replaced _extract_message_text with the canonical flatten_message_text from agent.message_content (already used by 7+ other modules)
  3. Removed a dead or content clause in _extract_item_text

kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Aug 21, 2026
…pre-checkpoint pruning

prune_pre_checkpoint_items() had a hardcoded role=='user' filter that
discarded all non-user messages before a checkpoint — including Hermes'
own compression summaries (role='assistant'), causing total context amnesia
about past conversation summaries.

The fix:
- _is_summary_item delegates to the canonical
  agent.context_compressor.is_compaction_summary_message provenance check
  (not an ad-hoc heuristic)
- Summaries are retained whole (never byte-sliced) within a 32k token budget
- Idempotent across repeated checkpoints (dedup by identical text)
- _chat_messages_to_responses_input threads item_sources (raw chat messages)
  through to the pruner, so it can read summary content directly from the
  source when the Responses conversion shape is lossy (tool-result carrier
  becomes function_call_output, or stale codex_message_items replay shadows
  merged content)

Fixes NousResearch#90975.

Salvage of NousResearch#90976 by @JoaoMarcos44.
kshitijk4poor pushed a commit that referenced this pull request Aug 21, 2026
…pre-checkpoint pruning

prune_pre_checkpoint_items() had a hardcoded role=='user' filter that
discarded all non-user messages before a checkpoint — including Hermes'
own compression summaries (role='assistant'), causing total context amnesia
about past conversation summaries.

The fix:
- _is_summary_item delegates to the canonical
  agent.context_compressor.is_compaction_summary_message provenance check
  (not an ad-hoc heuristic)
- Summaries are retained whole (never byte-sliced) within a 32k token budget
- Idempotent across repeated checkpoints (dedup by identical text)
- _chat_messages_to_responses_input threads item_sources (raw chat messages)
  through to the pruner, so it can read summary content directly from the
  source when the Responses conversion shape is lossy (tool-result carrier
  becomes function_call_output, or stale codex_message_items replay shadows
  merged content)

Fixes #90975.

Salvage of #90976 by @JoaoMarcos44.
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 21, 2026
Preserve valid normalized input_image user messages across native-compaction checkpoints at bounded one-token retention cost. Keep text extraction text-only, reject malformed or unknown multipart placeholders, and prove the production adapter path without claiming unsupported input_file behavior.

Refs NousResearch#90976 and NousResearch#91477.
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 21, 2026
Preserve valid normalized input_image user messages across native-compaction checkpoints at bounded one-token retention cost. Keep text extraction text-only, reject malformed or unknown multipart placeholders, and prove the production adapter path without claiming unsupported input_file behavior.

Refs NousResearch#90976 and NousResearch#91477.
andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 21, 2026
Preserve valid normalized input_image user messages across native-compaction checkpoints at bounded one-token retention cost. Keep text extraction text-only, reject malformed or unknown multipart placeholders, and prove the production adapter path without claiming unsupported input_file behavior.

Republish the identical source tree after an unrelated nondeterministic focus-redraw test failure; this commit contains no source delta from the previously verified object.

Refs NousResearch#90976 and NousResearch#91477.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround provider/openai OpenAI / Codex Responses API sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenAI native pruner discards local compression summaries pre-checkpoint

5 participants