فهرست منبع

fix: capture and round-trip thinking signature for Bedrock Claude (#11238)

* fix: capture and round-trip thinking signature for Bedrock Claude models

Bedrock handler streams reasoning text from Claude's extended thinking but
never captures the cryptographic signature. This causes 400 errors on
multi-turn conversations with tool use: 'Expected thinking or
redacted_thinking, but found tool_use'.

Changes:
- bedrock.ts: Capture reasoningContent.signature from Converse API stream
  deltas, implement getThoughtSignature() so Task.ts stores it as a proper
  thinking content block
- bedrock-converse-format.ts: Convert thinking blocks to Bedrock's
  reasoningContent format with signature, skip reasoning/redacted_thinking/
  thoughtSignature blocks that aren't valid for the API

* fix: add redacted_thinking round-trip, fix interface types, add tests

Address PR review feedback:
- Update ContentBlockDeltaEvent interface to include signature and
  redactedContent fields (removes type assertions)
- Add 6 tests for thinking/reasoning block conversions in
  bedrock-converse-format.ts

Also add redacted_thinking round-trip support:
- bedrock.ts: Capture redactedContent from stream deltas, base64 encode,
  expose via getRedactedThinkingBlocks()
- Task.ts: Insert redacted_thinking blocks after thinking block in
  assistant messages
- bedrock-converse-format.ts: Convert redacted_thinking blocks back to
  reasoningContent.redactedContent (base64 → Uint8Array)
Hannes Rudolph 1 هفته پیش
والد
کامیت
87f6d908c6

+ 50 - 0
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
+	}
 }

+ 135 - 0
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")
+		})
+	})
 })

+ 41 - 1
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,
 		}
 	})
 }

+ 10 - 0
src/core/task/Task.ts

@@ -1022,6 +1022,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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 = {