Skip to content

fix(api_server): avoid duplicating history when preflight compression rewrites transcript - #41700

Closed
xiaolongzhao2015 wants to merge 2 commits into
NousResearch:mainfrom
xiaolongzhao2015:fix/responses-compression-history-duplication
Closed

fix(api_server): avoid duplicating history when preflight compression rewrites transcript#41700
xiaolongzhao2015 wants to merge 2 commits into
NousResearch:mainfrom
xiaolongzhao2015:fix/responses-compression-history-duplication

Conversation

@xiaolongzhao2015

Copy link
Copy Markdown

Summary

/v1/responses was storing duplicated history in _response_store whenever the
AIAgent ran preflight compression mid-turn. Stored history grew uncapped across
chained turns (observed 994 → 1119 → 1358 messages over three follow-ups),
which in turn re-triggered preflight compression on every subsequent request —
preflight compression effectively never "stuck".

Root cause

APIServerAdapter._build_response_conversation_history decides whether
result["messages"] is a turn-only delta or a transcript-shaped full history by
checking whether it starts with prior + current_user (or just prior).

When the agent runs preflight compression, result["messages"] is the
post-compression full transcript. It does NOT start with the uncompressed
prior prefix, so the prefix match fails, the fallback branch fires, and the
adapter stores prior + current_user + compressed_messages — double-counting
every chained turn.

Fix

Add a secondary detection rule: if the agent-returned messages already contain
the current user turn (content-based match, robust to metadata like timestamp
/ name), trust them as a full transcript and return as-is. Only true
turn-only deltas (no current-user marker) still fall through to the legacy
prepend path, preserving existing behaviour for callers that did not run
compression.

Test plan

New regression test
TestResponsesEndpoint.test_previous_response_id_with_compressed_transcript_does_not_duplicate
asserts the stored history is exactly the compressed transcript (3 items)
rather than the bloated prior(40) + current(1) + compressed(3) = 44 items
that the buggy fallback produced.

  • python -m pytest tests/gateway/test_api_server.py -q158 passed

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery provider/openai OpenAI / Codex Responses API labels Jun 8, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Positive verification — history duplication regression fix.

The _messages_contain_user_turn content-based check is a clean guard against the 994→1119→1358 growth bug. When preflight compression rewrites the transcript, the returned agent_messages is the full post-compression history (not a delta), so the prefix-match fallback must not prepend prior again.

The static method is intentionally content-only (ignoring metadata like timestamp/name) which is the right invariant — compression may strip or rewrite metadata but preserves semantic content.

Test coverage is thorough: the regression test simulates the exact chained-turn scenario (3 turns with compression between turns 2 and 3) and verifies _response_store size stays bounded.

@melodyJie

Copy link
Copy Markdown

I had this problem too
I'm waiting for this issue to be fixed before updating my hermes version

@xiaolongzhao2015

Copy link
Copy Markdown
Author

@alt-glitch friendly ping — the CI workflow is still awaiting
maintainer approval on this PR. Could you kick it off when you get a chance?
Thanks!

@alt-glitch alt-glitch added the sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state label Jul 2, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026

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

Thanks for isolating the stale-prefix failure: current main still prepends the old history after _response_messages_turn_start_index() returns zero (gateway/platforms/api_server.py:3873-3885), so the proposed transcript guard addresses a real part of the bug.

Problems

  • Compression can rotate the agent session. _run_agent returns that effective ID in result["session_id"] (gateway/platforms/api_server.py:4128-4134), but Responses snapshots still persist the original request session_id (gateway/platforms/api_server.py:2760-2765; non-streaming storage at :3509-3514). This patch does not update either path, so the next previous_response_id request still uses the old session (:3324-3350).
  • The added test asserts only transcript replacement; it does not cover rotated-session continuation or streaming snapshots.

Suggested changes

  • Persist and return the effective result session ID for both Responses completion paths, and add chained rotated-session tests for streaming and non-streaming behavior.

Automated hermes-sweeper review.

# would double-count history and grow _response_store uncapped
# across chained turns (994 → 1119 → 1358 … bug). Fall through
# to the legacy prepend only for true turn-only deltas.
if APIServerAdapter._messages_contain_user_turn(

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.

This fixes the duplicated transcript branch, but compression can also rotate agent.session_id. _run_agent already returns that value in result["session_id"], while Responses snapshots still store the pre-run session_id; propagate the effective ID through both streaming and non-streaming persistence so the next previous_response_id call does not resume the old session.

… rewrites transcript

When the AIAgent runs preflight compression mid-turn, the returned
result["messages"] is the *post-compression* full transcript, which no
longer starts with the uncompressed prior history prefix that
_build_response_conversation_history uses to detect transcript-shaped
agent output.

The prefix match fails, the fallback branch fires, and the adapter
stores `prior + current_user + compressed_messages` in _response_store —
double-counting every chained turn. Stored history grows uncapped
(observed 994 -> 1119 -> 1358 messages across three follow-ups) and
preflight compression re-triggers on every subsequent request because
the bloated history keeps breaching the model context limit.

Add a secondary detection rule: if the agent-returned messages already
contain the current user turn (content-based match, robust to metadata
like timestamp/name), trust them as a full transcript and return as-is.
Only true turn-only deltas (no current-user marker) still fall through
to the legacy prepend path, preserving existing behaviour for callers
that did not run compression.

Regression covered by
TestResponsesEndpoint.test_previous_response_id_with_compressed_transcript_does_not_duplicate
which asserts the stored history is exactly the compressed transcript
(3 items) rather than the bloated prior(40) + current(1) + compressed(3)
that the buggy fallback produced.
Follow-up to teknium1's review on PR NousResearch#41700.

The transcript-fallback fix in this PR closed the visible bug (double
history growth), but the underlying compression path can also *rotate*
the agent's session_id mid-run. _run_agent surfaces the effective,
post-rotation value via `result["session_id"]`, yet the two Responses
completion paths still persisted the pre-run session id into
`_response_store`. Every subsequent request that chained via
`previous_response_id` therefore resumed the abandoned pre-compression
session — re-triggering compression on every turn.

Persist and return the effective result session id from both Responses
completion paths:

- Non-streaming (`_handle_responses`): read `result["session_id"]`,
  write it into the stored snapshot and the `X-Hermes-Session-Id`
  response header.
- Streaming (`_write_sse_responses`): track `effective_session_id`
  starting from the pre-run value; after `agent_task` completes, adopt
  `result["session_id"]` if present, so the terminal `response.completed`
  / `response.failed` snapshot records the rotated id. The initial
  `response.created` snapshot intentionally keeps the pre-run value —
  rotation hasn't happened yet at that point.

Add two regression tests that chain a third turn after a simulated
rotation and assert:

- The stored snapshot's `session_id` is the post-rotation value.
- The non-streaming response returns it via `X-Hermes-Session-Id`.
- The next `previous_response_id` request passes the post-rotation
  session id to `_run_agent`, so the agent resumes the same underlying
  session instead of resurrecting the abandoned pre-rotation row.

All 199 tests in `tests/gateway/test_api_server.py` pass under the
canonical `scripts/run_tests.sh` runner.
@xiaolongzhao2015
xiaolongzhao2015 force-pushed the fix/responses-compression-history-duplication branch from b485d80 to 38e0774 Compare July 14, 2026 14:01
@xiaolongzhao2015

Copy link
Copy Markdown
Author

Thanks for the deep dive @teknium1 — you're right that persisting the pre-run session id on the Responses side was papering over the compression rotation path. Pushed 38e0774 addressing exactly that.

Non-streaming (_handle_responses)
Read result["session_id"] after _run_agent returns (it's set by run_agent.py when agent.session_id was rotated mid-run), then use the effective value for both:

  • the stored snapshot in _response_store.put(...), and
  • the X-Hermes-Session-Id response header.

Streaming (_write_sse_responses)
Added a mutable effective_session_id (initialized to the pre-run value) that _persist_response_snapshot closes over. After await agent_task, if result["session_id"] is set, we adopt it — so the terminal response.completed / response.failed snapshot records the rotated id. The initial response.created snapshot intentionally keeps the pre-run value (the rotation hasn't happened yet at that point). SSE response headers are already flushed by then, so no header change is possible for the streaming path.

Tests
Two new regression tests that chain a third turn after a simulated rotation:

  • test_previous_response_id_uses_rotated_session_after_compression (non-streaming) — asserts X-Hermes-Session-Id header, stored snapshot session_id, and that the next chained request passes the post-rotation id to _run_agent.
  • test_streaming_previous_response_id_uses_rotated_session_after_compression — same assertions via SSE, parsing response.completed to recover the response id.

Full tests/gateway/test_api_server.py still green under scripts/run_tests.sh (199 passed).

@teknium1

Copy link
Copy Markdown
Contributor

Fixed via #69306 (merged), which salvaged #58133. You identified and fixed this root cause first (June 8) — first-submitter credit is yours in the merged PR body. The later branch was used as the merge vehicle only because it was already rebased onto the refactored _run_agent structure. Thanks for the original diagnosis!

@teknium1 teknium1 closed this Jul 22, 2026
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/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists provider/openai OpenAI / Codex Responses API sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

5 participants