mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
6a32b2e6fe
commit
87f6d908c6
4 changed files with 236 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue