Skip to content

fix: stop cross-provider thinking blocks from bricking a session - #103

Merged
mbuckaway merged 1 commit into
fork/mainfrom
fix/cross-provider-thinking-signature
Sep 8, 2026
Merged

mbuckaway merged 1 commit into
fork/mainfrom
fix/cross-provider-thinking-signature

Conversation

@mbuckaway

Copy link
Copy Markdown
Owner

Related Issue

No linked issue. This is a fork-internal bug fix against fork/main, so the upstream
external-PR policy (issue with a maintainer /approve) does not apply. The problem is
described in full below, and the fork carries a standalone diagnosis of it in
docs/CLAUDE-FIX.md.

Problem

Switching from a Codex or Gemini model to a Claude model inside one session made every
subsequent turn fail, permanently:

400 {"type":"error","error":{"type":"invalid_request_error",
"message":"messages.1.content.0: Invalid `signature` in `thinking` block"}}

ThinkPart carried a single untagged encrypted field, and three adapters wrote mutually
incompatible values into it — Anthropic its signature, OpenAI Responses a
reasoning.encrypted_content Fernet token, Google a thoughtSignature. Nothing recorded
which one produced a given blob, and nothing sanitized history on a model switch, so the
Anthropic adapter replayed a foreign blob verbatim as its own signature. Anthropic decrypts
signatures to verify them, so the request failed validation.

Two things made this unrecoverable rather than merely annoying:

  • The poisoned block sat in the first assistant message, so every later turn resent it.
    Retrying, forking the session, or picking a different Claude model all replayed it.
  • The existing recovery ladder could not help. isRecoverableRequestStructureError does not
    match this message, and even if it did, the structure: 'strict' re-projection it selects
    only dedupes tool calls, merges assistants and drops leading non-user messages — it never
    touches thinking blocks. The projector's wireSendableContent actively keeps signed
    thinking, so it protected the poison.

Reproduced against the live Anthropic API using the real blob from a bricked session: the
request returns 400 with the exact message above, and the identical request with that one
block removed returns 200.

What changed

Two independent layers, because prevention alone leaves every existing session dead and
recovery alone leaves the bug live.

Prevention. ThinkPart gains encryptedProtocol, typed as the existing Protocol
union so the tag cannot drift from the protocol ids the rest of the code already uses. Every
producer sets it; every consumer reads the blob through one shared helper that returns it
only when the tag matches. A mismatch falls through to the existing unsigned branch. An
untagged blob is still treated as compatible, so stored sessions keep their current
behaviour instead of depending on format guesswork — no sniffing of blob shapes.

Recovery, for sessions poisoned before this lands, whose think parts carry no tag. A new
isThinkingSignatureError classifies the rejection. A new thinking: 'strip' projection
axis rebuilds the request with every thinking and redacted_thinking block removed from
the whole history, leaving text and tool calls in place, and the request is resent once. The
recovery is recorded as a durable llm.thinking_stripped event folded into replayable agent
state, so a session pays the error at most once — across later turns and across reloads.

The classifier is deliberately not added to STRUCTURAL_REQUEST_MESSAGE_PATTERNS. That
array selects the strict re-projection, which cannot remove thinking, so routing this error
there would burn a retry and fail identically. It also deliberately excludes Anthropic
configuration errors ("thinking.type.enabled" is not supported for this model, adaptive thinking is not supported, block_binding: Extra inputs are not permitted), which need a
different remedy and must not be silently swallowed.

A total strip is what Anthropic documents as the remedy; partial stripping is what
triggers the separate blocks in the latest assistant message cannot be modified error. This
was verified live rather than assumed: a total strip is accepted (200) even when the latest
assistant turn carried thinking alongside a tool_use, on both an adaptive model
(claude-opus-5) and an extended-thinking budget-mode model (claude-haiku-4-5).

All three engines are covered. The legacy v1 engine gets the same rung through a
buildMessagesThinkingStripped builder, but v1 keeps its recovery flags in runTurn locals
and has no durable state, so a v1 session re-pays one rejection per turn. That asymmetry is
intentional and documented rather than papered over.

Verification

  • The three changed packages: 12,158 tests across 636 files, zero failures; tsc --noEmit
    clean for each; check-no-comments clean (agent-core-v2 is a comment-free zone).
  • End-to-end on real data: the request our own code builds from the bricked session's history
    returns 400 before the strip and 200 after it.

Note for reviewers on CI: this branch does not touch kap-server or minidb, but their
search/searchService, search/searchRoute and worker-build suites fail on Node v26 with
worker-spawn errors (SearchWorkerHost.doSpawn, RawPostingsWriter.open ENOENT). Both fail
identically in isolation on an unmodified tree, so they are pre-existing and environmental.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue — not applicable: fork-internal fix, problem described above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update — the bilingual VitePress docs
    describe no behaviour this changes.

ThinkPart carried a single untagged `encrypted` field that the Anthropic,
OpenAI Responses and Google GenAI adapters all wrote mutually incompatible
values into. After a mid-session model switch the Anthropic adapter replayed a
foreign reasoning blob as its own signature, and the API rejected it:

  messages.1.content.0: Invalid `signature` in `thinking` block

Because the block sat in the first assistant message, every later turn resent
it, so the session failed identically forever and could not be recovered by
retrying, forking or switching Claude models.

Tag the blob with the protocol that produced it and emit it only to that
protocol, falling through to the existing unsigned branch otherwise. An
untagged blob is still treated as compatible, so stored sessions keep their
current behaviour rather than relying on format guesswork.

Sessions poisoned before this change recover at runtime instead. Classify the
rejection with isThinkingSignatureError, add a `thinking: 'strip'` projection
axis that removes every thinking and redacted_thinking block from the history
while leaving text and tool calls in place, and resend once. The recovery is
recorded as a durable event and folded into replayable agent state, so a
session pays the error at most once across later turns and reloads.

A total strip is the documented remedy; partial stripping is what triggers the
separate "blocks in the latest assistant message cannot be modified" error.
Verified against the live API on both an adaptive and an extended-thinking
model that a total strip is accepted even when the latest assistant turn
carried thinking alongside a tool call.

The legacy engine gets the same rung through a message builder, but keeps its
recovery flags in runTurn locals and has no durable state, so a v1 session
re-pays one rejection per turn.
@mbuckaway
mbuckaway merged commit 39aa940 into fork/main Sep 8, 2026
13 checks passed
@mbuckaway
mbuckaway deleted the fix/cross-provider-thinking-signature branch September 8, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant