mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-14 19:24:54 +00:00
fix(anthropic): cascade-strip stale thinking siblings when their tool_use is orphaned (#8166)
* fix(anthropic): don't strip a trailing tool_use with no subsequent message Fixes #8159 cleanOrphanedToolCalls treated any assistant tool_use with no matching tool_result as an orphan and stripped it -- including a tool_use in the very last message, where there is no subsequent message to have found a result in yet. "No result yet" is not the same as "no result ever": the tool may simply not have finished executing, or the conversion may be happening for a reason other than sending the completed turn to Anthropic (token counting, a resumed/replayed session snapshot, a retry issued before tool execution completes, ...). Silently deleting a currently-active tool_use corrupts the assistant's most recent turn, and the damage compounds when that turn also carries a signed extended-thinking block: the block's signature is computed over the full sibling content of the turn, so removing the tool_use next to it invalidates the signature and replaying the mutated turn produces Anthropic 400 "thinking blocks in the latest assistant message cannot be modified" -- a genuinely confusing error for something the client did to its own outgoing request. Fix: when an assistant tool_use is in the last message of the array (no message follows it at all), treat its tool_use blocks as valid/unresolved rather than scanning for a match that can't exist yet. A tool_use is only condemned as orphaned when a subsequent message was actually scanned and found to lack a matching tool_result. Also fixed one existing test that unintentionally pinned the buggy behavior as expected output ("cleans orphaned tool_use blocks without matching tool_result" used a fixture with no subsequent message at all, which is the trailing case, not a genuine orphan) -- added a real next message with unrelated content so it now exercises an actual orphan. Added a new regression test for the trailing case. Verification: - New/updated unit tests in converter.test.ts (73 tests, was 72). - Full anthropicContentGenerator/ suite: 193 tests pass. - tsc --noEmit -p packages/core/tsconfig.json and eslint clean for touched files. * fix(anthropic): prune stale thinking signatures after a sibling tool_use is removed Fixes #8162 Anthropic validates a thinking/redacted_thinking block's opaque signature against the content it was originally computed over. Removing a sibling tool_use from that same turn -- whether in this request's own cleanup pass or in an earlier compaction cycle now baked into stored history -- can leave a thinking block whose signature no longer matches, producing: "thinking blocks in the latest assistant message cannot be modified" We independently hit this exact error text this session from a related cause (a different orphan-cleanup implementation stripping the *current* turn's tool_use and leaving its thinking sibling stale -- see #8159/ #8163), which is what prompted auditing this converter for the same class of gap. Two patches, ported from a downstream fork's previously-tested fix (closed a "residual class of 400 ... errors on Vertex-routed claude-opus-4.x sessions with adaptive thinking" per that fix's own commit message): PATCH-A (same-turn cascade, in cleanOrphanedToolCalls): when a tool_use is stripped from an assistant turn by this same cleanup pass, its thinking/redacted_thinking siblings in that turn are now cascade-removed too, since their signature was computed over content that included the now-gone tool_use. PATCH-B (cross-turn, new pruneUntrustworthyThinking pass): catches the case PATCH-A can't -- a non-latest assistant turn whose thinking survived earlier trims but whose tool_use was already gone by the time it entered this request's history. Only ever touches turns that are NOT the most recent assistant turn (Anthropic's contract requires the latest turn's signatures to replay byte-exact regardless). A strictly thinking-only older turn (no surviving tool_use, no other text) has its thinking downgraded to plain text so the model still sees the historical reasoning without an unreplayable signature; redacted_thinking has no plaintext fallback and is dropped instead. A turn that already carries real text alongside the untrustworthy thinking is left as-is (narrower than a blanket rewrite, matching the tested downstream fix). pruneUntrustworthyThinking is skipped when injectThinkingOnToolUseTurns is set (DeepSeek compatibility path): DeepSeek requires a synthetic empty thinking placeholder structurally on every tool-use turn and doesn't validate a signature the way Anthropic does, so this pass' "empty/untrustworthy thinking" concept doesn't apply there and would strip the very placeholder DeepSeek needs. IMPORTANT calibration note on live verification: I fully live-verified PATCH-A's mechanism this session via a related bug (#8159/#8163) and via code-reading of this exact cascade. For PATCH-B (the cross-turn case), I attempted a live reproduction against the real Anthropic Messages API (via our corporate proxy, Vertex-routed claude-sonnet-4-6, extended thinking enabled): obtained a genuine signed thinking+tool_use turn, then replayed it as a non-latest turn with the tool_use stripped but thinking intact, followed by a new user turn. This did NOT reproduce a 400 -- the API returned 200, with the model noticing the missing tool call itself and self-correcting in its response text rather than the server rejecting the malformed signature context. So the cross-turn mechanism, while structurally sound and matching an already-tested downstream fix's own historical diagnosis, is not independently live-confirmed via this proxy path in my environment. PATCH-B is still included as a defensive, non-regressive improvement (it can only ever remove content that's already been flagged as untrustworthy, never add risk), but I want reviewers to weigh this transparently rather than overclaim a live 400 I couldn't reproduce. Verification: - New tests: same-turn cascade (thinking dropped alongside its orphaned tool_use sibling), cross-turn thinking-only downgrade, cross-turn redacted_thinking-derived drop, latest-turn exemption, and narrower-scope-preserved (real text alongside stale thinking left untouched). - Full anthropicContentGenerator/ suite: 198 tests pass (was 193; net +5 tests). One pre-existing DeepSeek test required the injectThinkingOnToolUseTurns gate described above to keep passing. - tsc --noEmit -p packages/core/tsconfig.json and eslint clean for touched files. - Live proxy verification for PATCH-A's mechanism per above; PATCH-B's cross-turn case did not reproduce a 400 in my environment (see note above and the corresponding comment on #8162). * docs(anthropic): document pruneUntrustworthyThinking's false-positive heuristic Per review feedback on PR #8166: the function cannot distinguish 'this turn's tool_use was removed by an earlier trim' from 'this turn was always thinking-only' -- both are structurally identical by the time this pass runs (no surviving tool_use, no other text). Make that imprecision explicit in the doc comment rather than implying this only touches genuinely-stale turns, and note the live-verification gap (the specific 400 this guards against did not reproduce against a Vertex-routed proxy). * refactor(anthropic): narrow to the same-turn cascade, drop the cross-turn heuristic Per wenshao's live-verified review on #8166: split PATCH-A and PATCH-B, keep PATCH-A plus the one genuine fix inside PATCH-B, drop the rest. Kept and refined (PATCH-A, cleanOrphanedToolCalls's same-turn cascade): scoped the thinking/redacted_thinking cascade to only fire when NO tool_use survives the same turn, not merely "any tool_use removed" (S2). A turn shaped [thinking, tool_use A, tool_use B] where only B is a genuine orphan now keeps both the surviving tool_use A and its thinking sibling -- previously the thinking was stripped even though A still needed it to satisfy Anthropic's manual-mode "final turn must begin with thinking when a tool_use is present" rule, trading one 400 for another. Kept (S6, extracted into its own function, dropEmptyTextThinkingBlocks): an unconditional guard dropping any thinking block with empty text on a non-latest assistant turn. This is unconditionally correct regardless of tool_use presence and isn't redundant with the existing dropUnsignedThinkingFromAssistantMessages pass, which is itself gated to non-native-baseURL + adaptive-thinking + non-DeepSeek configs -- this guard also covers native Anthropic API sessions that pass never touches. Must run AFTER dropUnsignedThinkingFromAssistantMessages, not before: that pass has a deliberate fail-loud design -- a thinking block with a missing/empty signature on a turn inside the still-active tool-use chain throws rather than silently drops, since Claude requires all of an active loop's thinking blocks to be passed back complete and unmodified. An empty-text, unsigned redacted_thinking-derived block is unsigned by that same definition; if dropEmptyTextThinkingBlocks ran first it deleted the block before the fail-loud check ever saw it, silently swallowing exactly the proxy bug that throw exists to surface. This is the identical pass-ordering hazard the removed PATCH-B heuristic was rejected for, reintroduced by this refactor's own extraction -- caught in review and fixed here by reordering, with a regression test. Dropped entirely (the broader pruneUntrustworthyThinking heuristic): "a non-latest, thinking-only turn with no surviving tool_use is structurally untrustworthy, downgrade its thinking to text." Removed because: - Pass ordering: it ran before dropUnsignedThinkingFromAssistantMessages for the same reason described above -- re-typing untrustworthy thinking to plain text first made the drop pass no longer recognize it as thinking at all, so genuinely unsigned reasoning that should have been removed was instead sent to the model as assistant text. - Its DeepSeek exclusion only covered thinking-on mode (!injectThinkingOnToolUseTurns); DeepSeek-with-thinking-off went through a different, deliberately-permissive strip pass that this heuristic wasn't gated against, rewriting a shape that pass intentionally leaves alone. - Live A/B verification against a real session showed a thinking-only turn (that never carried a tool_use, so its signature was never actually invalidated) getting re-typed to text one request later, purely because a newer assistant turn had displaced it as "latest" -- changing the serialized prefix between two consecutive requests (invalidating a cache breakpoint) and costing extra tokens on last-turn-only models that would otherwise strip prior-turn thinking for free. - The state this heuristic exists to clean up -- a non-latest turn whose tool_use went stale in an earlier trim -- did not reproduce against this codebase's actual machinery: chatCompressionService compresses the full history rather than partially trimming it (confirmed the post-compaction attachment path preserves a trailing functionCall turn's thinking sibling intact rather than splitting it), and repairOrphanedToolUseTurns already synthesizes an error tool_result for a genuine cross-turn orphan on every send, upstream of the converter, before this pass would ever see it. truncateHistory and stripThoughtsFromHistory were also checked and don't produce the target state either. Also fixes two bot review Suggestions on the current diff: a duplicated JSDoc paragraph in cleanOrphanedToolCalls's doc comment, and missing test coverage for the branch where a cascade empties a turn out entirely (the message must be dropped and its surrounding user turns merged). --------- Co-authored-by: Palanisamy, Dinesh <Dinesh.Palanisamy@netapp.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
parent
196549dc81
commit
c1539df4d3
2 changed files with 413 additions and 5 deletions
|
|
@ -1148,6 +1148,113 @@ describe('AnthropicContentConverter', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass', () => {
|
||||
// A thinking block's signature is computed over the full sibling
|
||||
// content of its turn. If a sibling tool_use is stripped as an
|
||||
// orphan, the signature no longer matches and replaying it 400s:
|
||||
// "thinking blocks in the latest assistant message cannot be
|
||||
// modified". So the thinking block must go with it.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
|
||||
{ text: 'Let me help' },
|
||||
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'never mind' }] },
|
||||
],
|
||||
});
|
||||
|
||||
const assistantMsg = messages.find((m) => m.role === 'assistant');
|
||||
expect(assistantMsg).toBeDefined();
|
||||
expect(assistantMsg!.content).toEqual([
|
||||
{ type: 'text', text: 'Let me help' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not cascade-strip thinking when a sibling tool_use survives alongside an orphaned one', () => {
|
||||
// Partial-orphan case: turn = [thinking, tool_use A, tool_use B],
|
||||
// only A's result comes back -- B is a genuine orphan and is
|
||||
// stripped, but A survives. The thinking sibling must stay too: it's
|
||||
// still needed to satisfy Anthropic's manual-mode "final turn must
|
||||
// begin with thinking when a tool_use is present" rule, and
|
||||
// cascading here would trade one 400 for another.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
|
||||
{ functionCall: { id: 'a', name: 'tool', args: {} } },
|
||||
{ functionCall: { id: 'b', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'a',
|
||||
name: 'tool',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assistantMsg = messages.find((m) => m.role === 'assistant');
|
||||
expect(assistantMsg).toBeDefined();
|
||||
const blocks = assistantMsg!.content as Array<{ type: string }>;
|
||||
expect(blocks[0]?.type).toBe('thinking');
|
||||
expect(blocks.some((b) => b.type === 'tool_use')).toBe(true);
|
||||
expect(blocks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('drops the whole message and merges surrounding user turns when a cascade empties out the turn entirely', () => {
|
||||
// The bot review's flagged coverage gap: the only existing cascade
|
||||
// test leaves a surviving `text` block, so `finalBlocks` is never
|
||||
// empty and the `else` drop branch in cleanOrphanedToolCalls is
|
||||
// never exercised. Here the turn's only blocks are a signed thinking
|
||||
// part and an orphaned tool_use, so after the cascade strips both,
|
||||
// finalBlocks is empty and the whole assistant message must be
|
||||
// dropped -- and the surrounding user messages must merge.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'before' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
|
||||
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'after' }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages.some((m) => m.role === 'assistant')).toBe(false);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]!.role).toBe('user');
|
||||
expect(messages[0]!.content).toEqual([
|
||||
{ type: 'text', text: 'before' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'after',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('cleans orphaned tool_result blocks without matching tool_use', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
|
|
@ -1944,6 +2051,77 @@ describe('AnthropicContentConverter', () => {
|
|||
).toThrow('proxy omitted the thinking signature');
|
||||
});
|
||||
|
||||
it('fails locally, rather than silently dropping the block, when an EMPTY-text unsigned thinking block belongs to a non-latest step of an active tool-use loop', () => {
|
||||
// Regression guard for pipeline ordering: dropEmptyTextThinkingBlocks
|
||||
// must run AFTER this check, not before. An empty-text thinking
|
||||
// block with no signature is unsigned by the same definition this
|
||||
// active-loop check uses -- if the empty-text guard ran first it
|
||||
// would delete the block before this check ever saw it, silently
|
||||
// swallowing exactly the proxy bug this throw exists to surface
|
||||
// (the same pass-ordering hazard identified against the removed
|
||||
// PATCH-B heuristic).
|
||||
//
|
||||
// Needs a two-step loop: a single assistant turn is always "the
|
||||
// latest", and dropEmptyTextThinkingBlocks unconditionally exempts
|
||||
// the latest turn regardless of ordering, so a one-step fixture
|
||||
// can't distinguish the two orderings. Step 1's empty-text thinking
|
||||
// must be on a NON-latest turn that is still part of the unbroken
|
||||
// tool_use/tool_result chain reaching the end of history.
|
||||
expect(() =>
|
||||
converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Run tool' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: '', thought: true },
|
||||
{ functionCall: { id: 't1', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 't1',
|
||||
name: 'tool',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'signed reasoning',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
{ functionCall: { id: 't2', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 't2',
|
||||
name: 'tool',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ dropUnsignedAssistantThinking: true },
|
||||
),
|
||||
).toThrow('proxy omitted the thinking signature');
|
||||
});
|
||||
|
||||
it('drops unsigned thinking from a completed tool-use turn', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
|
|
@ -2039,6 +2217,95 @@ describe('AnthropicContentConverter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('dropEmptyTextThinkingBlocks', () => {
|
||||
it('leaves a signed, non-empty thinking block on a non-latest turn untouched', () => {
|
||||
// A broader cross-turn heuristic here (detecting "this turn's
|
||||
// tool_use went stale in an earlier trim" and downgrading its
|
||||
// thinking to text) was removed after review: it couldn't
|
||||
// distinguish that state from "this turn was always thinking-only",
|
||||
// and live verification showed it rewriting turns that were never
|
||||
// actually invalid. Only an empty-text thinking block is
|
||||
// unconditionally invalid regardless of tool_use presence; a
|
||||
// populated, signed thinking block is left exactly as-is.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'stale reasoning',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'anything else?' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
});
|
||||
|
||||
const olderAssistant = messages[1];
|
||||
expect(olderAssistant.role).toBe('assistant');
|
||||
expect(olderAssistant.content).toEqual([
|
||||
{ type: 'thinking', thinking: 'stale reasoning', signature: 'sig' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => {
|
||||
// convertAnthropicResponseToGemini represents a redacted_thinking
|
||||
// block as `{ text: '', thought: true }` (its opaque `data` doesn't
|
||||
// survive the Gemini-Part round trip -- see that method's doc). When
|
||||
// this round-trips back through processContent it becomes an
|
||||
// empty-text `thinking` block on the wire, which this defensive
|
||||
// guard drops outright, dropping the whole message since nothing
|
||||
// else survives.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: '', thought: true }],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'anything else?' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
});
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant');
|
||||
expect(assistantMessages).toHaveLength(1);
|
||||
expect(assistantMessages[0].content).toEqual([
|
||||
{ type: 'text', text: 'Sure, here you go.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the latest assistant turn untouched even with empty-text thinking', () => {
|
||||
// The latestAssistantIdx short-circuit fires before the empty-text
|
||||
// filter runs at all, so this must hold regardless of content -- use
|
||||
// an actually-empty-text block (matching the title) rather than a
|
||||
// populated one, so this test would fail if the exemption were ever
|
||||
// narrowed to "non-empty-text latest turns only".
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: '', thought: true, thoughtSignature: 'sig' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
expect(lastMsg.role).toBe('assistant');
|
||||
expect(lastMsg.content).toEqual([
|
||||
{ type: 'thinking', thinking: '', signature: 'sig' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's
|
||||
// anthropic-compatible API rejects requests in thinking mode when a prior
|
||||
// assistant turn carrying `tool_use` omits a thinking block. Plain-text
|
||||
|
|
|
|||
|
|
@ -276,9 +276,33 @@ export class AnthropicContentConverter {
|
|||
messages = mergeConsecutiveAssistantMessages(messages);
|
||||
messages = cleanOrphanedToolCalls(messages);
|
||||
messages = mergeConsecutiveAssistantMessages(messages);
|
||||
// Must run BEFORE dropEmptyTextThinkingBlocks: dropUnsignedThinking...
|
||||
// throws when an unsigned thinking block belongs to a turn that's part
|
||||
// of an unbroken, still-active tool_use/tool_result chain reaching the
|
||||
// end of history -- a real proxy bug that should fail loudly rather
|
||||
// than silently continue. An empty-text thinking block with no
|
||||
// signature is unsigned by this same definition; if the empty-text
|
||||
// guard ran first it would delete the block outright before this
|
||||
// check ever saw it, silently swallowing exactly the proxy bug this
|
||||
// throw exists to surface (the same pass-ordering hazard raised
|
||||
// against the removed PATCH-B heuristic, which retyped instead of
|
||||
// deleted but had the identical effect of hiding the block from this
|
||||
// check).
|
||||
if (options.dropUnsignedAssistantThinking) {
|
||||
messages = this.dropUnsignedThinkingFromAssistantMessages(messages);
|
||||
}
|
||||
// Defense-in-depth against an empty-text thinking block surviving into
|
||||
// a non-latest turn (see dropEmptyTextThinkingBlocks's doc) -- e.g. one
|
||||
// that DOES carry a signature, so dropUnsignedThinkingFromAssistant...
|
||||
// above leaves it alone. Skipped for DeepSeek's injectThinkingOnToolUseTurns
|
||||
// path: DeepSeek's synthetic thinking placeholder (injected above) is
|
||||
// deliberately `{type:'thinking', thinking:'', signature:''}` on every
|
||||
// tool-use turn, and DeepSeek doesn't validate a signature the way
|
||||
// Anthropic does, so this guard would strip the very placeholder
|
||||
// DeepSeek needs.
|
||||
if (!options.injectThinkingOnToolUseTurns) {
|
||||
messages = dropEmptyTextThinkingBlocks(messages);
|
||||
}
|
||||
if (options.stripAssistantThinking) {
|
||||
this.stripThinkingFromAssistantMessages(messages);
|
||||
}
|
||||
|
|
@ -1427,6 +1451,19 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean {
|
|||
* Remove tool_use blocks that have no matching tool_result in the
|
||||
* immediately following user message, and remove tool_result blocks that
|
||||
* have no matching tool_use in the immediately preceding assistant message.
|
||||
* Also cascade-strips `thinking`/`redacted_thinking` blocks from an
|
||||
* assistant turn whenever a `tool_use` is removed from that same turn by
|
||||
* this pass AND no other `tool_use` survives in it -- the signature on
|
||||
* those blocks was computed over content that included the now-removed
|
||||
* `tool_use`, so replaying it produces Anthropic 400 "thinking blocks in
|
||||
* the latest assistant message cannot be modified". The model regenerates
|
||||
* thinking on its next turn regardless. Scoped to "no surviving tool_use"
|
||||
* rather than "any tool_use removed": a turn with `[thinking, tool_use A,
|
||||
* tool_use B]` where only B is a genuine orphan still sends A on the wire,
|
||||
* and per Anthropic's manual-mode extended-thinking contract the final
|
||||
* assistant turn of a thinking-enabled request must begin with a thinking
|
||||
* block when any `tool_use` remains in it -- stripping the thinking here
|
||||
* would trade one 400 for another.
|
||||
*
|
||||
* A `tool_use` in the very last message (no message follows it at all) is
|
||||
* never condemned as orphaned here -- "no result yet" isn't the same as
|
||||
|
|
@ -1438,8 +1475,10 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean {
|
|||
* matching `tool_result` is a genuine orphan.
|
||||
*
|
||||
* Empty messages produced by the cleanup are dropped entirely. A subsequent
|
||||
* mergeConsecutiveAssistantMessages call fixes any alternation issues
|
||||
* created by dropped messages.
|
||||
* mergeConsecutiveAssistantMessages call fixes alternation issues created
|
||||
* by a dropped assistant message sandwiched between two other assistant
|
||||
* messages; mergeConsecutiveUserMessages (later in the pipeline) does the
|
||||
* same when the sandwiching messages are user turns instead.
|
||||
*
|
||||
* Mirrors the same-name function in the OpenAI converter.
|
||||
*/
|
||||
|
|
@ -1525,13 +1564,16 @@ function cleanOrphanedToolCalls(
|
|||
continue;
|
||||
}
|
||||
|
||||
let toolUseRemoved = false;
|
||||
const keepToolResult = makeToolResultDeduper();
|
||||
|
||||
const filtered = blocks.filter((b) => {
|
||||
const t = (b as { type?: string }).type;
|
||||
if (t === 'tool_use') {
|
||||
const id = (b as { id?: string }).id;
|
||||
return !id || validToolUseBlocks.has(b as object);
|
||||
const keep = !id || validToolUseBlocks.has(b as object);
|
||||
if (!keep) toolUseRemoved = true;
|
||||
return keep;
|
||||
}
|
||||
if (t === 'tool_result') {
|
||||
const id = (b as { tool_use_id?: string }).tool_use_id;
|
||||
|
|
@ -1542,8 +1584,28 @@ function cleanOrphanedToolCalls(
|
|||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
cleaned.push({ ...message, content: filtered });
|
||||
// A tool_use was stripped from this turn and none survives -- any
|
||||
// thinking/redacted_thinking sibling in the same turn is now
|
||||
// untrustworthy (see function doc). If a tool_use survives, the
|
||||
// thinking sibling is left in place: it's still needed to satisfy
|
||||
// Anthropic's manual-mode "final turn must begin with thinking when a
|
||||
// tool_use is present" rule, and only cascading on total removal keeps
|
||||
// this narrower than a blanket "any removal" rule. tool_use/thinking
|
||||
// only ever co-occur on assistant messages, but the role check is
|
||||
// defensive.
|
||||
const survivingToolUse = filtered.some(
|
||||
(b) => (b as { type?: string }).type === 'tool_use',
|
||||
);
|
||||
const finalBlocks =
|
||||
toolUseRemoved && !survivingToolUse && message.role === 'assistant'
|
||||
? filtered.filter((b) => {
|
||||
const t = (b as { type?: string }).type;
|
||||
return t !== 'thinking' && t !== 'redacted_thinking';
|
||||
})
|
||||
: filtered;
|
||||
|
||||
if (finalBlocks.length > 0) {
|
||||
cleaned.push({ ...message, content: finalBlocks });
|
||||
} else {
|
||||
debugLogger.debug(
|
||||
'cleanOrphanedToolCalls: dropping message with only orphaned tool blocks',
|
||||
|
|
@ -1554,6 +1616,85 @@ function cleanOrphanedToolCalls(
|
|||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops any `thinking` block with empty text from a non-latest assistant
|
||||
* turn (dropping the whole message if that empties it out). An Anthropic
|
||||
* `thinking` block's signature is computed over its own text content; a
|
||||
* block with no text at all cannot represent valid signed reasoning
|
||||
* regardless of whether a signature is present. This arises when a
|
||||
* `redacted_thinking` block -- whose opaque `data` doesn't survive the
|
||||
* Gemini-`Part` round trip, see
|
||||
* {@link AnthropicContentConverter.convertAnthropicResponseToGemini} --
|
||||
* is replayed back through history construction as an empty-text
|
||||
* `thinking` block.
|
||||
*
|
||||
* Scoped to non-latest assistant turns, matching Anthropic's contract that
|
||||
* the latest assistant turn's signatures must replay byte-exact.
|
||||
*
|
||||
* This was originally one guard inside a larger `pruneUntrustworthyThinking`
|
||||
* pass that also tried to detect and downgrade a non-latest, thinking-only
|
||||
* turn whose `tool_use` had gone stale in an earlier trim (a cross-turn
|
||||
* complement to {@link cleanOrphanedToolCalls}'s same-turn cascade). That
|
||||
* broader heuristic was removed after review: it could not distinguish "this
|
||||
* turn's tool_use was removed by an earlier trim" from "this turn was
|
||||
* always thinking-only" (both are structurally identical by the time it
|
||||
* ran), it ran before the passes that already handle unsigned thinking
|
||||
* correctly (reordering caused them to stop recognizing thinking it had
|
||||
* already re-typed as text), its DeepSeek exclusion only covered one of
|
||||
* DeepSeek's two thinking modes, and live A/B verification against a real
|
||||
* session showed it re-typing a thinking-only turn on the very next
|
||||
* request just because a newer assistant turn had been appended --
|
||||
* invalidating a cache breakpoint and adding token cost for content that
|
||||
* was never actually invalid. Investigation into this codebase's actual
|
||||
* compaction (`chatCompressionService` is full-history, not a partial
|
||||
* trim that could strand a `tool_use`) and orphan-repair
|
||||
* (`repairOrphanedToolUseTurns` already synthesizes an error
|
||||
* `tool_result` for a genuine cross-turn orphan before it would reach this
|
||||
* pass) did not reproduce the state the broader heuristic existed to
|
||||
* clean up. This guard is the one part of that pass that is unconditionally
|
||||
* correct regardless of that heuristic's premise, so it's kept on its own.
|
||||
*/
|
||||
function dropEmptyTextThinkingBlocks(
|
||||
messages: AnthropicMessageParam[],
|
||||
): AnthropicMessageParam[] {
|
||||
let latestAssistantIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.role === 'assistant') {
|
||||
latestAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const out: AnthropicMessageParam[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (i === latestAssistantIdx) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const blocks = msg.content as AnthropicContentBlockParam[];
|
||||
const filtered = blocks.filter((raw) => {
|
||||
const bType = (raw as { type?: string }).type;
|
||||
const bThinkingRaw = (raw as { thinking?: unknown }).thinking;
|
||||
const bThinking =
|
||||
typeof bThinkingRaw === 'string' ? bThinkingRaw : undefined;
|
||||
return !(
|
||||
bType === 'thinking' &&
|
||||
(bThinking === undefined || bThinking.length === 0)
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) continue;
|
||||
out.push({ role: msg.role, content: filtered });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeConsecutiveUserMessages(
|
||||
messages: AnthropicMessageParam[],
|
||||
): AnthropicMessageParam[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue