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)
This commit is contained in:
Hannes Rudolph 2026-02-05 18:53:00 -07:00 committed by GitHub
parent 6a32b2e6fe
commit 87f6d908c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 236 additions and 1 deletions

View file

@ -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
}
}

View file

@ -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")
})
})
})

View file

@ -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,
}
})
}

View file

@ -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 = {