diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 6bcf57d42a..ca747439ef 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -125,8 +125,11 @@ interface ContentBlockDeltaEvent { thinking?: string type?: string // AWS SDK structure for reasoning content deltas + // Includes text (reasoning), signature (verification token), and redactedContent (safety-filtered) reasoningContent?: { text?: string + signature?: string + redactedContent?: Uint8Array } // Tool use input delta toolUse?: { @@ -201,6 +204,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH private client: BedrockRuntimeClient private arnInfo: any private readonly providerName = "Bedrock" + private lastThoughtSignature: string | undefined + private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ProviderSettings) { super() @@ -491,6 +496,10 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH throw new Error("No stream available in the response") } + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] + for await (const chunk of response.stream) { // Parse the chunk as JSON if it's a string (for tests) let streamEvent: StreamEvent @@ -642,6 +651,27 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH continue } + // Capture the thinking signature from reasoningContent.signature delta. + // Bedrock Converse API sends the signature as a separate delta after all + // reasoning text deltas. This signature must be round-tripped back for + // multi-turn conversations with tool use (Anthropic API requirement). + if (delta.reasoningContent?.signature) { + this.lastThoughtSignature = delta.reasoningContent.signature + continue + } + + // Capture redacted thinking content (opaque binary data from safety-filtered reasoning). + // Anthropic returns this when extended thinking content is filtered. It must be + // passed back verbatim in multi-turn conversations for proper reasoning continuity. + if (delta.reasoningContent?.redactedContent) { + const redactedContent = delta.reasoningContent.redactedContent + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: Buffer.from(redactedContent).toString("base64"), + }) + continue + } + // Handle tool use input delta if (delta.toolUse?.input) { yield { @@ -1579,4 +1609,24 @@ Please check: return `Bedrock completion error: ${errorMessage}` } } + + /** + * Returns the thinking signature captured from the last Bedrock Converse API response. + * Claude models with extended thinking return a cryptographic signature in the + * reasoning content delta, which must be round-tripped back for multi-turn + * conversations with tool use (Anthropic API requirement). + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + + /** + * Returns any redacted thinking blocks captured from the last Bedrock response. + * Anthropic returns these when safety filters trigger on the model's internal + * reasoning. They contain opaque binary data (base64-encoded) that must be + * passed back verbatim for proper reasoning continuity. + */ + getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { + return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined + } } diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 27319c6562..f8c3c9f016 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -556,4 +556,139 @@ describe("convertToBedrockConverseMessages", () => { } }) }) + + describe("thinking and reasoning block handling", () => { + it("should convert thinking blocks to reasoningContent format", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think about this...", signature: "sig-abc123" } as any, + { type: "text", text: "Here is my answer." }, + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + expect(result[0].content).toHaveLength(2) + + const reasoningBlock = result[0].content![0] as any + expect(reasoningBlock.reasoningContent).toBeDefined() + expect(reasoningBlock.reasoningContent.reasoningText.text).toBe("Let me think about this...") + expect(reasoningBlock.reasoningContent.reasoningText.signature).toBe("sig-abc123") + + const textBlock = result[0].content![1] as any + expect(textBlock.text).toBe("Here is my answer.") + }) + + it("should convert redacted_thinking blocks with data to reasoningContent.redactedContent", () => { + const testData = Buffer.from("encrypted-redacted-content").toString("base64") + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "redacted_thinking", data: testData } as any, { type: "text", text: "Response" }], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + + const redactedBlock = result[0].content![0] as any + expect(redactedBlock.reasoningContent).toBeDefined() + expect(redactedBlock.reasoningContent.redactedContent).toBeInstanceOf(Uint8Array) + // Verify round-trip: decode back and compare + const decoded = Buffer.from(redactedBlock.reasoningContent.redactedContent).toString("utf-8") + expect(decoded).toBe("encrypted-redacted-content") + }) + + it("should skip redacted_thinking blocks without data", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "redacted_thinking" } as any, { type: "text", text: "Response" }], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + // Only the text block should remain (redacted_thinking without data is filtered out) + expect(result[0].content).toHaveLength(1) + expect((result[0].content![0] as any).text).toBe("Response") + }) + + it("should skip reasoning blocks (internal Roo Code format)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Internal reasoning" } as any, + { type: "text", text: "Response" }, + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect((result[0].content![0] as any).text).toBe("Response") + }) + + it("should skip thoughtSignature blocks (Gemini format)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Response" }, + { type: "thoughtSignature", thoughtSignature: "gemini-sig" } as any, + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect((result[0].content![0] as any).text).toBe("Response") + }) + + it("should handle full thinking + redacted_thinking + text + tool_use message", () => { + const redactedData = Buffer.from("redacted-binary").toString("base64") + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Deep thought", signature: "sig-xyz" } as any, + { type: "redacted_thinking", data: redactedData } as any, + { type: "text", text: "I'll use a tool." }, + { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.txt" } }, + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(4) + + // thinking → reasoningContent.reasoningText + expect((result[0].content![0] as any).reasoningContent.reasoningText.text).toBe("Deep thought") + expect((result[0].content![0] as any).reasoningContent.reasoningText.signature).toBe("sig-xyz") + + // redacted_thinking → reasoningContent.redactedContent + expect((result[0].content![1] as any).reasoningContent.redactedContent).toBeInstanceOf(Uint8Array) + + // text + expect((result[0].content![2] as any).text).toBe("I'll use a tool.") + + // tool_use → toolUse + expect((result[0].content![3] as any).toolUse.name).toBe("read_file") + }) + }) }) diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index 2a49d72bce..1a77513f43 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -195,15 +195,55 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } as ContentBlock } + // Handle Anthropic thinking blocks (stored by Task.ts for extended thinking) + // Convert to Bedrock Converse API's reasoningContent format + const blockAny = block as { type: string; thinking?: string; signature?: string } + if (blockAny.type === "thinking" && blockAny.thinking) { + return { + reasoningContent: { + reasoningText: { + text: blockAny.thinking, + signature: blockAny.signature, + }, + }, + } as ContentBlock + } + + // Handle redacted thinking blocks (Anthropic sends these when content is filtered). + // Convert base64-encoded data back to Uint8Array for Bedrock Converse API's + // reasoningContent.redactedContent format. + if (blockAny.type === "redacted_thinking" && (blockAny as unknown as { data?: string }).data) { + const base64Data = (blockAny as unknown as { data: string }).data + const binaryData = Buffer.from(base64Data, "base64") + return { + reasoningContent: { + redactedContent: new Uint8Array(binaryData), + }, + } as ContentBlock + } + + // Skip redacted_thinking blocks without data (shouldn't happen, but be safe) + if (blockAny.type === "redacted_thinking") { + return undefined as unknown as ContentBlock + } + + // Skip reasoning blocks (internal Roo Code format, not for the API) + if (blockAny.type === "reasoning" || blockAny.type === "thoughtSignature") { + return undefined as unknown as ContentBlock + } + // Default case for unknown block types return { text: "[Unknown Block Type]", } as ContentBlock }) + // Filter out undefined entries (from skipped block types like redacted_thinking, reasoning) + const filteredContent = content.filter((block): block is ContentBlock => block != null) + return { role, - content, + content: filteredContent, } }) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 00ceee680b..abbded6329 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1022,6 +1022,7 @@ export class Task extends EventEmitter implements TaskLike { getThoughtSignature?: () => string | undefined getSummary?: () => any[] | undefined getReasoningDetails?: () => any[] | undefined + getRedactedThinkingBlocks?: () => Array<{ type: "redacted_thinking"; data: string }> | undefined } if (message.role === "assistant") { @@ -1072,6 +1073,15 @@ export class Task extends EventEmitter implements TaskLike { } else if (!messageWithTs.content) { messageWithTs.content = [thinkingBlock] } + + // Also insert any redacted_thinking blocks after the thinking block. + // Anthropic returns these when safety filters trigger on reasoning content. + // They must be passed back verbatim for proper reasoning continuity. + const redactedBlocks = handler.getRedactedThinkingBlocks?.() + if (redactedBlocks && Array.isArray(messageWithTs.content)) { + // Insert after the thinking block (index 1, right after thinking at index 0) + messageWithTs.content.splice(1, 0, ...redactedBlocks) + } } else if (reasoning && !reasoningDetails) { // Other providers (non-Anthropic): Store as generic reasoning block const reasoningBlock = {