fix(agent-core): drop vacuous assistant messages that permanently wedge sessions#1968
Conversation
…ge sessions A provider-filtered response can seal an assistant message holding only an empty thinking part into the recorded history. The projection's empty-message guard only counted parts, so it survived, and was serialized as an assistant message with no content and no tool calls — which the provider rejects with "the message at position N with role 'assistant' must not be empty" on every resend, permanently wedging the session. The projection now drops any message whose parts all serialize to nothing (empty/whitespace text, or empty unsigned thinking). Content-bearing messages keep every part verbatim, including empty thinking blocks that preserved-thinking providers require back. Drops surface through a new vacuous_message_dropped projection anomaly with log/telemetry counters, and the structural-error recovery now recognizes this provider rejection so the strict resend self-heals any residual path. Mirrored in agent-core-v2.
🦋 Changeset detectedLatest commit: d7a59ad The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c0bce6d17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The v2 comment convention keeps comments solely in the top-of-file block. Move the vacuous-message drop rationale there and drop the inline/JSDoc comments added beside statements in the projector service, the llmProtocol error patterns, and the projector tests. No behavior change.
The loop-event fold already dropped a settled assistant message when it was structurally empty (no content, no tool calls), but a step that recorded only an empty thinking part — e.g. a provider-filtered response carrying an empty reasoning field — survived into history, relying on the projection to keep it off the wire. Widen the settle condition to drop the step whenever nothing sendable was recorded (no tool calls; every content part vacuous), using a predicate now shared with the context projector. This heals stored v2 histories at restore time instead of only at request time.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23229bf4b7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…cuous content The strict resend's dedupe pass could re-create the exact empty-assistant shape the projection now guards against: when every tool call a message carried was removed as a later duplicate, a remainder holding only an empty thinking part was kept (its content was non-empty), serialized as an assistant message with no content and no tool calls, and rejected again with "the message at position N with role 'assistant' must not be empty" — the same failure the strict resend was recovering from. The keep condition now requires sendable content, so such a message is dropped wholesale with a vacuous_message_dropped anomaly alongside the duplicate-tool-call one. Mirrored in agent-core-v2.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f36d46a3c9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…educer The live loop-event fold drops a settled assistant step when nothing sendable was recorded (no tool calls; every content part vacuous), but the cold transcript reducer still pushed an assistant on every step.begin and never dropped it. Cold readers (snapshot / messages) therefore kept showing output-free phantom assistants — including one per failed retry attempt, which predates the vacuous-step case — and foldedLength overcounted the live folded history, which can misplace the unflushed-tail splice in the messages view while a turn is in flight. The reducer now settles steps the same way the fold does: at a step's end or at the next begin, an assistant with no tool calls and only vacuous content is removed and foldedLength is decremented. Steps carrying any sendable output — real text, real thinking, signed thinking, or tool calls — are kept verbatim.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4302327a8c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b28a6f9845
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) { | ||
| return [...closed.slice(0, index), ...closed.slice(index + 1)]; |
There was a problem hiding this comment.
Preserve legacy compaction tails when dropping vacuous steps
When replaying an older v2 wire log whose context.apply_compaction record lacks keptUserMessageCount, buildContextCompactionShape keeps the old tail with history.slice(compactedCount). If a vacuous assistant before that compaction is now removed here, the replayed history is shorter than the persisted compactedCount, so the slice skips the first real retained tail message; for example [user, empty-think step, user] with compactedCount: 2 replays to just the summary instead of summary plus the tail user. Please compensate the legacy count for dropped steps or avoid shrinking the replay state before legacy compaction applies.
Useful? React with 👍 / 👎.
Related Issue
No linked issue — the problem is explained below (diagnosed from a user-exported debug session).
Problem
After a provider safety filter blocks a response (
finish_reason: content_filter), the session can get permanently stuck. The filtered stream carries an emptyreasoning_contentfield, which the provider adapter records as an empty thinking part; the loop then seals an assistant message whose only content is that empty thinking part into the conversation history. The projection's empty-message guard only counted parts, so the message survived; on the wire it serialized as{role:'assistant', reasoning_content:''}— no content, no tool calls — and the provider rejected every subsequent request with400 the message at position N with role 'assistant' must not be empty. Every later turn (resume, steer, cron) re-sent the same poisoned history and failed at the same position. The structural-error recovery did not recognize this 400 phrasing, so the strict resend never fired either.What changed
vacuous_message_droppedprojection anomaly, counted in the repair warning log and thecontext_projection_repairedtelemetry event, so this class of history corruption stays observable.Tests: new projection cases in both engines (empty-think-only dropped; empty think kept on content-bearing messages; signed empty think kept; whitespace-only think dropped; tool-calling assistant kept), pattern-match cases for the new 400 phrasing in both error classifiers, and an updated telemetry payload assertion. Verified end-to-end against the real incident wire log: replaying it through the projection no longer emits the poisoned message.
Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.