You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a model response is cut off at the output-token ceiling (finish.reason.kind === 'max-tokens'), the agent loop persists an assistant message whose content and source.replayState disagree. Every subsequent replay of that session then throws INVALID_REPLAY_STATE (invalid pi-ai replay state: block count does not match assistant content), and the session is permanently unusable without manually editing the zstd session log. Sessions forked from the poisoned one (e.g. via "new session" flows that copy records) inherit the poison.
Reproduced in production on 0.1.0-rc.5 and 0.1.0-rc.6; the code is identical on master (verified at 47f9438).
Root cause
Two components each behave "correctly" in isolation, but the loop composes them inconsistently:
packages/llm/llm/src/assembler.ts — BlockAssembler.blocks() deliberately drops tool-call blocks on a max-tokens finish, because a tool call truncated mid-arguments cannot be executed safely:
packages/llm/llm-pi-ai (adapter) projects the full response — truncated tool call included — into finish.replayState (that is its job: lossless replay of complete responses).
packages/core/agent-loop/src/agent.ts (≈ line 373) records the message with both the filtered content and the unfiltered replay state:
On the next turn, replayedAssistant in packages/llm/llm-pi-ai/src/replay.ts validates the stored record and throws:
if (state.blocks.length !== message.content.length)
return invalidReplay('block count does not match assistant content')
Production trigger
A Qwen3-27B-class model at a 32768 output-token ceiling, cut mid tool-call (stopReason: "length", tool arguments: "{}"). The stored record had 1 content block (text) vs 2 replayState blocks (text + tool-call). Every turn after that failed with INVALID_REPLAY_STATE, including freshly forked "new" sessions, because fork copies all records.
Why it is severe
The failure is deterministic and self-perpetuating: the poisoned record is part of the history, so no amount of retrying helps.
"Start a new session" does not help if the new session is forked from the old one (session lineage copies records).
Recovery currently requires hand-editing the zstd session log (drop the stale source.replayState from the offending record) — not something end users can do.
The error surfaces late (at the next replay), far from the cause (the previous turn's finish), making diagnosis hard.
Proposed fix
When the finish reason is max-tokens, do not record the adapter replay state — the truncated stream is not a complete response, so the message must replay as foreign history (content is authoritative; the dropped tool call was never executed, so nothing is lost):
+ // On a max-tokens finish `blocks()` has dropped the tool-call blocks it+ // cannot execute safely, so a replay state projected from the full+ // response no longer describes the recorded content and would fail the+ // next replay with INVALID_REPLAY_STATE. Record the message as foreign.+ const replayState = finish.kind === 'max-tokens' ? undefined : assembler.replayState
const message = createAssistantMessage({
content: assembler.blocks(),
source: {
provider: request.provider,
model: request.model,
- ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},+ ...replayState !== undefined ? { replayState } : {},
},
})
This has been running in production (dual-instance deployment, QQ bot + web UI) since 2026-08-16 with zero recurrences.
An alternative that preserves replay state for the (valid) text-only max-tokens case would be to have the assembler expose the dropped indices and project the state to match blocks() — more invasive, and foreign replay is behaviorally identical for dropped tool calls.
Regression test
A scripted-stream regression test (fails on master, passes with the fix) is attached to the accompanying PR: packages/core/agent-loop/tests/max-tokens-replay-state-regression.spec.ts — it streams [completed text block][tool-call deltas, no block-end][finish max-tokens + 2-block replayState] and asserts the recorded message carries no replay state.
Environment
DSH 0.1.0-rc.5 / 0.1.0-rc.6 (master 47f9438 has the same code paths)
Repro does not require a real model: see regression test
Fix & regression test ready: the change (3 lines + a scripted MockAdapter regression test that fails on master and passes with the fix) is committed at fork branch bluewowo:deepseek-harness@fix/max-tokens-replay-state-persist — available for whoever picks this up. Happy to adapt the patch to the maintainers' preferred shape (e.g. projecting the replay state to match blocks() instead of dropping it).
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Summary
When a model response is cut off at the output-token ceiling (
finish.reason.kind === 'max-tokens'), the agent loop persists an assistant message whosecontentandsource.replayStatedisagree. Every subsequent replay of that session then throwsINVALID_REPLAY_STATE(invalid pi-ai replay state: block count does not match assistant content), and the session is permanently unusable without manually editing the zstd session log. Sessions forked from the poisoned one (e.g. via "new session" flows that copy records) inherit the poison.Reproduced in production on
0.1.0-rc.5and0.1.0-rc.6; the code is identical onmaster(verified at 47f9438).Root cause
Two components each behave "correctly" in isolation, but the loop composes them inconsistently:
packages/llm/llm/src/assembler.ts—BlockAssembler.blocks()deliberately drops tool-call blocks on amax-tokensfinish, because a tool call truncated mid-arguments cannot be executed safely:packages/llm/llm-pi-ai(adapter) projects the full response — truncated tool call included — intofinish.replayState(that is its job: lossless replay of complete responses).packages/core/agent-loop/src/agent.ts(≈ line 373) records the message with both the filtered content and the unfiltered replay state:On the next turn,
replayedAssistantinpackages/llm/llm-pi-ai/src/replay.tsvalidates the stored record and throws:Production trigger
A Qwen3-27B-class model at a 32768 output-token ceiling, cut mid tool-call (
stopReason: "length", toolarguments: "{}"). The stored record had 1 content block (text) vs 2 replayState blocks (text + tool-call). Every turn after that failed withINVALID_REPLAY_STATE, including freshly forked "new" sessions, because fork copies all records.Why it is severe
source.replayStatefrom the offending record) — not something end users can do.Proposed fix
When the finish reason is
max-tokens, do not record the adapter replay state — the truncated stream is not a complete response, so the message must replay as foreign history (content is authoritative; the dropped tool call was never executed, so nothing is lost):This has been running in production (dual-instance deployment, QQ bot + web UI) since 2026-08-16 with zero recurrences.
An alternative that preserves replay state for the (valid) text-only max-tokens case would be to have the assembler expose the dropped indices and project the state to match
blocks()— more invasive, and foreign replay is behaviorally identical for dropped tool calls.Regression test
A scripted-stream regression test (fails on master, passes with the fix) is attached to the accompanying PR:
packages/core/agent-loop/tests/max-tokens-replay-state-regression.spec.ts— it streams[completed text block][tool-call deltas, no block-end][finish max-tokens + 2-block replayState]and asserts the recorded message carries no replay state.Environment
0.1.0-rc.5/0.1.0-rc.6(master 47f9438 has the same code paths)Fix & regression test ready: the change (3 lines + a scripted
MockAdapterregression test that fails on master and passes with the fix) is committed at fork branchbluewowo:deepseek-harness@fix/max-tokens-replay-state-persist— available for whoever picks this up. Happy to adapt the patch to the maintainers' preferred shape (e.g. projecting the replay state to matchblocks()instead of dropping it).All reactions