diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index 9851b81a139d..78c8f18459d6 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -1585,6 +1585,95 @@ describe("anthropic transport stream", () => { ]); }); + it("replaces a completed thinking-only turn when the current request disables thinking", async () => { + await runTransportStream( + makeAnthropicTransportModel(), + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "stop", + timestamp: 0, + content: [ + { + type: "thinking", + thinking: "private reasoning", + thinkingSignature: "sig_1", + }, + { + type: "thinking", + thinking: "[Reasoning redacted]", + thinkingSignature: "opaque_1", + redacted: true, + }, + ], + }, + { role: "user", content: "again" }, + ], + } as AnthropicStreamContext, + { + apiKey: "sk-ant-api", + } as AnthropicStreamOptions, + ); + + const payload = latestAnthropicRequest().payload; + const assistantMessage = findRecord(payload.messages, (record) => record.role === "assistant"); + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(assistantMessage.content).toEqual([ + { type: "text", text: "[assistant reasoning omitted]" }, + ]); + }); + + it("preserves signed thinking for an active tool turn when new thinking is disabled", async () => { + await runTransportStream( + makeAnthropicTransportModel(), + { + messages: [ + { role: "user", content: "look it up" }, + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "toolUse", + timestamp: 0, + content: [ + { + type: "thinking", + thinking: "call lookup", + thinkingSignature: "sig_tool", + }, + { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "lookup", + content: [{ type: "text", text: "42" }], + isError: false, + }, + ], + } as AnthropicStreamContext, + { + apiKey: "sk-ant-api", + } as AnthropicStreamOptions, + ); + + const assistantMessage = findRecord( + latestAnthropicRequest().payload.messages, + (record) => record.role === "assistant", + ); + expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "call lookup", signature: "sig_tool" }, + { type: "tool_use", id: "call_1", name: "lookup", input: {} }, + ]); + }); + it("backfills empty reasoning_content thinking blocks for compatible Anthropic tool-use replays", async () => { await runTransportStream( makeAnthropicTransportModel({ diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index f8887511c5fc..cbf54eaca97f 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -6,6 +6,10 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { getEnvApiKey } from "../llm/env-api-keys.js"; import { calculateCost, clampThinkingLevel } from "../llm/model-utils.js"; +import { + ANTHROPIC_OMITTED_REASONING_TEXT, + findActiveAnthropicToolTurnAssistantIndex, +} from "../llm/providers/anthropic-thinking-replay.js"; import type { AnthropicOptions } from "../llm/providers/anthropic.js"; import type { AssistantMessageDiagnostic, @@ -372,11 +376,18 @@ function convertAnthropicMessages( messages: Context["messages"], model: AnthropicTransportModel, isOAuthToken: boolean, - options?: { allowReasoningContentReplay?: boolean }, + options?: { + allowReasoningContentReplay?: boolean; + replayThinkingEnabled?: boolean; + }, ) { const params: Array> = []; const allowReasoningContentReplay = options?.allowReasoningContentReplay === true; + const replayThinkingEnabled = options?.replayThinkingEnabled !== false; const transformedMessages = transformTransportMessages(messages, model, normalizeToolCallId); + const activeToolTurnAssistantIndex = replayThinkingEnabled + ? -1 + : findActiveAnthropicToolTurnAssistantIndex(transformedMessages); for (let i = 0; i < transformedMessages.length; i += 1) { const msg = transformedMessages[i]; if (msg.role === "user") { @@ -428,6 +439,7 @@ function convertAnthropicMessages( if (msg.role === "assistant") { const blocks: Array> = []; const reasoningContent: string[] = []; + let omittedThinking = false; for (const block of msg.content) { if (block.type === "text") { if (block.text.trim().length > 0) { @@ -439,6 +451,12 @@ function convertAnthropicMessages( continue; } if (block.type === "thinking") { + const thinkingSignature = block.thinkingSignature?.trim(); + const isReasoningContent = thinkingSignature === "reasoning_content"; + if (!replayThinkingEnabled && i !== activeToolTurnAssistantIndex && !isReasoningContent) { + omittedThinking = true; + continue; + } if (block.redacted) { blocks.push({ type: "redacted_thinking", @@ -446,9 +464,7 @@ function convertAnthropicMessages( }); continue; } - const thinkingSignature = block.thinkingSignature?.trim(); - const hasNativeThinkingSignature = - Boolean(thinkingSignature) && thinkingSignature !== "reasoning_content"; + const hasNativeThinkingSignature = Boolean(thinkingSignature) && !isReasoningContent; if (block.thinking.trim().length === 0 && !hasNativeThinkingSignature) { continue; } @@ -490,6 +506,9 @@ function convertAnthropicMessages( }); } } + if (blocks.length === 0 && omittedThinking) { + blocks.push({ type: "text", text: ANTHROPIC_OMITTED_REASONING_TEXT }); + } if (blocks.length > 0) { const assistantMsg: Record = { role: "assistant", content: blocks }; if (reasoningContent.length > 0) { @@ -899,6 +918,8 @@ function buildAnthropicParams( isOAuthToken: boolean, options: AnthropicTransportOptions | undefined, ) { + const fable5 = usesClaudeFable5MessagesContract(model); + const replayThinkingEnabled = fable5 || options?.thinkingEnabled === true; const maxTokens = resolveAnthropicMessagesMaxTokens({ modelMaxTokens: model.maxTokens, requestedMaxTokens: options?.maxTokens, @@ -920,6 +941,7 @@ function buildAnthropicParams( messages: ensureNonEmptyAnthropicMessages( convertAnthropicMessages(context.messages, model, isOAuthToken, { allowReasoningContentReplay: supportsReasoningContentReplay(model), + replayThinkingEnabled, }), ), max_tokens: maxTokens, @@ -961,7 +983,6 @@ function buildAnthropicParams( if (context.tools) { params.tools = convertAnthropicTools(context.tools, isOAuthToken); } - const fable5 = usesClaudeFable5MessagesContract(model); if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) { if (fable5 || options?.thinkingEnabled) { if (supportsAdaptiveThinking(model)) { diff --git a/src/llm/providers/anthropic-thinking-replay.ts b/src/llm/providers/anthropic-thinking-replay.ts new file mode 100644 index 000000000000..1ceabdca1963 --- /dev/null +++ b/src/llm/providers/anthropic-thinking-replay.ts @@ -0,0 +1,58 @@ +type ReplayMessage = { + role?: unknown; + content?: unknown; + toolCallId?: unknown; +}; + +export const ANTHROPIC_OMITTED_REASONING_TEXT = "[assistant reasoning omitted]"; + +function asReplayMessage(value: unknown): ReplayMessage | undefined { + return value && typeof value === "object" ? (value as ReplayMessage) : undefined; +} + +/** + * Anthropic tool results continue the preceding assistant turn. Preserve that + * turn's signed thinking even when the next request disables new thinking. + */ +export function findActiveAnthropicToolTurnAssistantIndex(messages: readonly unknown[]): number { + const toolResultIds = new Set(); + let index = messages.length - 1; + + while (index >= 0) { + const message = asReplayMessage(messages[index]); + if (message?.role !== "toolResult") { + break; + } + if (typeof message.toolCallId === "string") { + toolResultIds.add(message.toolCallId); + } + index -= 1; + } + + if (toolResultIds.size === 0) { + return -1; + } + + const assistant = asReplayMessage(messages[index]); + if (assistant?.role !== "assistant" || !Array.isArray(assistant.content)) { + return -1; + } + + const toolCallIds = new Set(); + for (const block of assistant.content) { + if (!block || typeof block !== "object") { + continue; + } + const record = block as { type?: unknown; id?: unknown }; + if ( + (record.type === "toolCall" || + record.type === "tool_use" || + record.type === "function_call") && + typeof record.id === "string" + ) { + toolCallIds.add(record.id); + } + } + + return [...toolResultIds].every((toolCallId) => toolCallIds.has(toolCallId)) ? index : -1; +} diff --git a/src/llm/providers/anthropic.test.ts b/src/llm/providers/anthropic.test.ts index a500fb208f42..dd899037c79d 100644 --- a/src/llm/providers/anthropic.test.ts +++ b/src/llm/providers/anthropic.test.ts @@ -242,6 +242,148 @@ describe("Anthropic provider", () => { expect(result.responseModel).toBe("claude-fable-5"); }); + it.each([ + { + label: "omitted", + thinkingEnabled: undefined, + expectedThinking: undefined, + visibleText: undefined, + expectedContent: [{ type: "text", text: "[assistant reasoning omitted]" }], + }, + { + label: "explicitly disabled", + thinkingEnabled: false, + expectedThinking: { type: "disabled" }, + visibleText: "Visible answer.", + expectedContent: [{ type: "text", text: "Visible answer." }], + }, + ])( + "omits completed-turn thinking when thinking is $label", + async ({ thinkingEnabled, expectedThinking, visibleText, expectedContent }) => { + let capturedPayload: unknown; + const stream = streamAnthropic( + makeAnthropicModel(), + { + messages: [ + { role: "user", content: "hello", timestamp: 0 }, + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "stop", + timestamp: 0, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + content: [ + { + type: "thinking", + thinking: "private reasoning", + thinkingSignature: "sig_1", + }, + { + type: "thinking", + thinking: "[Reasoning redacted]", + thinkingSignature: "opaque_1", + redacted: true, + }, + ...(visibleText ? [{ type: "text" as const, text: visibleText }] : []), + ], + }, + { role: "user", content: "again", timestamp: 0 }, + ], + }, + { + apiKey: "sk-ant-provider", + thinkingEnabled, + onPayload: (payload) => { + capturedPayload = payload; + throw new Error("stop before network"); + }, + }, + ); + + await stream.result(); + + const payload = capturedPayload as { + messages: Array<{ role: string; content: unknown[] }>; + thinking?: unknown; + }; + expect(payload.thinking).toEqual(expectedThinking); + expect(payload.messages.find((message) => message.role === "assistant")?.content).toEqual( + expectedContent, + ); + }, + ); + + it("preserves signed thinking for an active tool turn when new thinking is disabled", async () => { + let capturedPayload: unknown; + const stream = streamAnthropic( + makeAnthropicModel(), + { + messages: [ + { role: "user", content: "look it up", timestamp: 0 }, + { + role: "assistant", + provider: "anthropic", + api: "anthropic-messages", + model: "claude-sonnet-4-6", + stopReason: "toolUse", + timestamp: 0, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + content: [ + { + type: "thinking", + thinking: "call lookup", + thinkingSignature: "sig_tool", + }, + { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "lookup", + content: [{ type: "text", text: "42" }], + isError: false, + timestamp: 0, + }, + ], + }, + { + apiKey: "sk-ant-provider", + thinkingEnabled: false, + onPayload: (payload) => { + capturedPayload = payload; + throw new Error("stop before network"); + }, + }, + ); + + await stream.result(); + + const payload = capturedPayload as { + messages: Array<{ role: string; content: unknown[] }>; + }; + expect(payload.messages.find((message) => message.role === "assistant")?.content).toEqual([ + { type: "thinking", thinking: "call lookup", signature: "sig_tool" }, + { type: "tool_use", id: "call_1", name: "lookup", input: {} }, + ]); + }); + it.each([ ["anthropic", "sk-ant-provider"], ["anthropic-vertex", "vertex-token"], diff --git a/src/llm/providers/anthropic.ts b/src/llm/providers/anthropic.ts index 25d56bd933a0..4d13eeb6ddf1 100644 --- a/src/llm/providers/anthropic.ts +++ b/src/llm/providers/anthropic.ts @@ -50,6 +50,10 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { + ANTHROPIC_OMITTED_REASONING_TEXT, + findActiveAnthropicToolTurnAssistantIndex, +} from "./anthropic-thinking-replay.js"; import { resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; @@ -1054,6 +1058,8 @@ function buildParams( isOAuthTokenResult: boolean, options?: AnthropicOptions, ): MessageCreateParamsStreaming { + const fable5 = usesClaudeFable5MessagesContract(model); + const replayThinkingEnabled = fable5 || options?.thinkingEnabled === true; const { cacheControl } = getCacheControl(model, options?.cacheRetention); const system = buildAnthropicSystemBlocks(context.systemPrompt, isOAuthTokenResult, cacheControl); const compat = context.tools?.length ? getAnthropicCompat(model) : undefined; @@ -1080,6 +1086,7 @@ function buildParams( isOAuthTokenResult, cacheControl, messageCacheControlLimit, + replayThinkingEnabled, ), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, @@ -1109,7 +1116,6 @@ function buildParams( // Configure thinking mode: always-on adaptive (Fable 5), adaptive (Opus // 4.6+ and Sonnet 4.6), // budget-based (older models), or explicitly disabled. - const fable5 = usesClaudeFable5MessagesContract(model); if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) { if (fable5 || options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7+ and Mythos Preview behave like @@ -1166,11 +1172,15 @@ function convertMessages( isOAuthTokenValue: boolean, cacheControl?: CacheControlEphemeral, messageCacheControlLimit = 4, + replayThinkingEnabled = true, ): MessageParam[] { const params: MessageParam[] = []; // Transform messages for cross-provider compatibility const transformedMessages = transformMessages(messages, model, normalizeToolCallId); + const activeToolTurnAssistantIndex = replayThinkingEnabled + ? -1 + : findActiveAnthropicToolTurnAssistantIndex(transformedMessages); for (let i = 0; i < transformedMessages.length; i++) { const msg = transformedMessages[i]; @@ -1216,6 +1226,7 @@ function convertMessages( } } else if (msg.role === "assistant") { const blocks: ContentBlockParam[] = []; + let omittedThinking = false; for (const block of msg.content) { if (block.type === "text") { @@ -1227,6 +1238,10 @@ function convertMessages( text: sanitizeSurrogates(block.text), }); } else if (block.type === "thinking") { + if (!replayThinkingEnabled && i !== activeToolTurnAssistantIndex) { + omittedThinking = true; + continue; + } // Redacted thinking: pass the opaque payload back as redacted_thinking if (block.redacted) { blocks.push({ @@ -1270,6 +1285,9 @@ function convertMessages( }); } } + if (blocks.length === 0 && omittedThinking) { + blocks.push({ type: "text", text: ANTHROPIC_OMITTED_REASONING_TEXT }); + } if (blocks.length === 0) { continue; }