Skip to content

Commit 87f6d90

Browse files
fix: capture and round-trip thinking signature for Bedrock Claude (RooCodeInc#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)
1 parent 6a32b2e commit 87f6d90

4 files changed

Lines changed: 236 additions & 1 deletion

File tree

src/api/providers/bedrock.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,11 @@ interface ContentBlockDeltaEvent {
125125
thinking?: string
126126
type?: string
127127
// AWS SDK structure for reasoning content deltas
128+
// Includes text (reasoning), signature (verification token), and redactedContent (safety-filtered)
128129
reasoningContent?: {
129130
text?: string
131+
signature?: string
132+
redactedContent?: Uint8Array
130133
}
131134
// Tool use input delta
132135
toolUse?: {
@@ -201,6 +204,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
201204
private client: BedrockRuntimeClient
202205
private arnInfo: any
203206
private readonly providerName = "Bedrock"
207+
private lastThoughtSignature: string | undefined
208+
private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = []
204209

205210
constructor(options: ProviderSettings) {
206211
super()
@@ -491,6 +496,10 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
491496
throw new Error("No stream available in the response")
492497
}
493498

499+
// Reset thinking state for this request
500+
this.lastThoughtSignature = undefined
501+
this.lastRedactedThinkingBlocks = []
502+
494503
for await (const chunk of response.stream) {
495504
// Parse the chunk as JSON if it's a string (for tests)
496505
let streamEvent: StreamEvent
@@ -642,6 +651,27 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
642651
continue
643652
}
644653

654+
// Capture the thinking signature from reasoningContent.signature delta.
655+
// Bedrock Converse API sends the signature as a separate delta after all
656+
// reasoning text deltas. This signature must be round-tripped back for
657+
// multi-turn conversations with tool use (Anthropic API requirement).
658+
if (delta.reasoningContent?.signature) {
659+
this.lastThoughtSignature = delta.reasoningContent.signature
660+
continue
661+
}
662+
663+
// Capture redacted thinking content (opaque binary data from safety-filtered reasoning).
664+
// Anthropic returns this when extended thinking content is filtered. It must be
665+
// passed back verbatim in multi-turn conversations for proper reasoning continuity.
666+
if (delta.reasoningContent?.redactedContent) {
667+
const redactedContent = delta.reasoningContent.redactedContent
668+
this.lastRedactedThinkingBlocks.push({
669+
type: "redacted_thinking",
670+
data: Buffer.from(redactedContent).toString("base64"),
671+
})
672+
continue
673+
}
674+
645675
// Handle tool use input delta
646676
if (delta.toolUse?.input) {
647677
yield {
@@ -1579,4 +1609,24 @@ Please check:
15791609
return `Bedrock completion error: ${errorMessage}`
15801610
}
15811611
}
1612+
1613+
/**
1614+
* Returns the thinking signature captured from the last Bedrock Converse API response.
1615+
* Claude models with extended thinking return a cryptographic signature in the
1616+
* reasoning content delta, which must be round-tripped back for multi-turn
1617+
* conversations with tool use (Anthropic API requirement).
1618+
*/
1619+
getThoughtSignature(): string | undefined {
1620+
return this.lastThoughtSignature
1621+
}
1622+
1623+
/**
1624+
* Returns any redacted thinking blocks captured from the last Bedrock response.
1625+
* Anthropic returns these when safety filters trigger on the model's internal
1626+
* reasoning. They contain opaque binary data (base64-encoded) that must be
1627+
* passed back verbatim for proper reasoning continuity.
1628+
*/
1629+
getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined {
1630+
return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined
1631+
}
15821632
}

src/api/transform/__tests__/bedrock-converse-format.spec.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,4 +556,139 @@ describe("convertToBedrockConverseMessages", () => {
556556
}
557557
})
558558
})
559+
560+
describe("thinking and reasoning block handling", () => {
561+
it("should convert thinking blocks to reasoningContent format", () => {
562+
const messages: Anthropic.Messages.MessageParam[] = [
563+
{
564+
role: "assistant",
565+
content: [
566+
{ type: "thinking", thinking: "Let me think about this...", signature: "sig-abc123" } as any,
567+
{ type: "text", text: "Here is my answer." },
568+
],
569+
},
570+
]
571+
572+
const result = convertToBedrockConverseMessages(messages)
573+
574+
expect(result).toHaveLength(1)
575+
expect(result[0].role).toBe("assistant")
576+
expect(result[0].content).toHaveLength(2)
577+
578+
const reasoningBlock = result[0].content![0] as any
579+
expect(reasoningBlock.reasoningContent).toBeDefined()
580+
expect(reasoningBlock.reasoningContent.reasoningText.text).toBe("Let me think about this...")
581+
expect(reasoningBlock.reasoningContent.reasoningText.signature).toBe("sig-abc123")
582+
583+
const textBlock = result[0].content![1] as any
584+
expect(textBlock.text).toBe("Here is my answer.")
585+
})
586+
587+
it("should convert redacted_thinking blocks with data to reasoningContent.redactedContent", () => {
588+
const testData = Buffer.from("encrypted-redacted-content").toString("base64")
589+
const messages: Anthropic.Messages.MessageParam[] = [
590+
{
591+
role: "assistant",
592+
content: [{ type: "redacted_thinking", data: testData } as any, { type: "text", text: "Response" }],
593+
},
594+
]
595+
596+
const result = convertToBedrockConverseMessages(messages)
597+
598+
expect(result).toHaveLength(1)
599+
expect(result[0].content).toHaveLength(2)
600+
601+
const redactedBlock = result[0].content![0] as any
602+
expect(redactedBlock.reasoningContent).toBeDefined()
603+
expect(redactedBlock.reasoningContent.redactedContent).toBeInstanceOf(Uint8Array)
604+
// Verify round-trip: decode back and compare
605+
const decoded = Buffer.from(redactedBlock.reasoningContent.redactedContent).toString("utf-8")
606+
expect(decoded).toBe("encrypted-redacted-content")
607+
})
608+
609+
it("should skip redacted_thinking blocks without data", () => {
610+
const messages: Anthropic.Messages.MessageParam[] = [
611+
{
612+
role: "assistant",
613+
content: [{ type: "redacted_thinking" } as any, { type: "text", text: "Response" }],
614+
},
615+
]
616+
617+
const result = convertToBedrockConverseMessages(messages)
618+
619+
expect(result).toHaveLength(1)
620+
// Only the text block should remain (redacted_thinking without data is filtered out)
621+
expect(result[0].content).toHaveLength(1)
622+
expect((result[0].content![0] as any).text).toBe("Response")
623+
})
624+
625+
it("should skip reasoning blocks (internal Roo Code format)", () => {
626+
const messages: Anthropic.Messages.MessageParam[] = [
627+
{
628+
role: "assistant",
629+
content: [
630+
{ type: "reasoning", text: "Internal reasoning" } as any,
631+
{ type: "text", text: "Response" },
632+
],
633+
},
634+
]
635+
636+
const result = convertToBedrockConverseMessages(messages)
637+
638+
expect(result).toHaveLength(1)
639+
expect(result[0].content).toHaveLength(1)
640+
expect((result[0].content![0] as any).text).toBe("Response")
641+
})
642+
643+
it("should skip thoughtSignature blocks (Gemini format)", () => {
644+
const messages: Anthropic.Messages.MessageParam[] = [
645+
{
646+
role: "assistant",
647+
content: [
648+
{ type: "text", text: "Response" },
649+
{ type: "thoughtSignature", thoughtSignature: "gemini-sig" } as any,
650+
],
651+
},
652+
]
653+
654+
const result = convertToBedrockConverseMessages(messages)
655+
656+
expect(result).toHaveLength(1)
657+
expect(result[0].content).toHaveLength(1)
658+
expect((result[0].content![0] as any).text).toBe("Response")
659+
})
660+
661+
it("should handle full thinking + redacted_thinking + text + tool_use message", () => {
662+
const redactedData = Buffer.from("redacted-binary").toString("base64")
663+
const messages: Anthropic.Messages.MessageParam[] = [
664+
{
665+
role: "assistant",
666+
content: [
667+
{ type: "thinking", thinking: "Deep thought", signature: "sig-xyz" } as any,
668+
{ type: "redacted_thinking", data: redactedData } as any,
669+
{ type: "text", text: "I'll use a tool." },
670+
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.txt" } },
671+
],
672+
},
673+
]
674+
675+
const result = convertToBedrockConverseMessages(messages)
676+
677+
expect(result).toHaveLength(1)
678+
expect(result[0].content).toHaveLength(4)
679+
680+
// thinking → reasoningContent.reasoningText
681+
expect((result[0].content![0] as any).reasoningContent.reasoningText.text).toBe("Deep thought")
682+
expect((result[0].content![0] as any).reasoningContent.reasoningText.signature).toBe("sig-xyz")
683+
684+
// redacted_thinking → reasoningContent.redactedContent
685+
expect((result[0].content![1] as any).reasoningContent.redactedContent).toBeInstanceOf(Uint8Array)
686+
687+
// text
688+
expect((result[0].content![2] as any).text).toBe("I'll use a tool.")
689+
690+
// tool_use → toolUse
691+
expect((result[0].content![3] as any).toolUse.name).toBe("read_file")
692+
})
693+
})
559694
})

src/api/transform/bedrock-converse-format.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,55 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
195195
} as ContentBlock
196196
}
197197

198+
// Handle Anthropic thinking blocks (stored by Task.ts for extended thinking)
199+
// Convert to Bedrock Converse API's reasoningContent format
200+
const blockAny = block as { type: string; thinking?: string; signature?: string }
201+
if (blockAny.type === "thinking" && blockAny.thinking) {
202+
return {
203+
reasoningContent: {
204+
reasoningText: {
205+
text: blockAny.thinking,
206+
signature: blockAny.signature,
207+
},
208+
},
209+
} as ContentBlock
210+
}
211+
212+
// Handle redacted thinking blocks (Anthropic sends these when content is filtered).
213+
// Convert base64-encoded data back to Uint8Array for Bedrock Converse API's
214+
// reasoningContent.redactedContent format.
215+
if (blockAny.type === "redacted_thinking" && (blockAny as unknown as { data?: string }).data) {
216+
const base64Data = (blockAny as unknown as { data: string }).data
217+
const binaryData = Buffer.from(base64Data, "base64")
218+
return {
219+
reasoningContent: {
220+
redactedContent: new Uint8Array(binaryData),
221+
},
222+
} as ContentBlock
223+
}
224+
225+
// Skip redacted_thinking blocks without data (shouldn't happen, but be safe)
226+
if (blockAny.type === "redacted_thinking") {
227+
return undefined as unknown as ContentBlock
228+
}
229+
230+
// Skip reasoning blocks (internal Roo Code format, not for the API)
231+
if (blockAny.type === "reasoning" || blockAny.type === "thoughtSignature") {
232+
return undefined as unknown as ContentBlock
233+
}
234+
198235
// Default case for unknown block types
199236
return {
200237
text: "[Unknown Block Type]",
201238
} as ContentBlock
202239
})
203240

241+
// Filter out undefined entries (from skipped block types like redacted_thinking, reasoning)
242+
const filteredContent = content.filter((block): block is ContentBlock => block != null)
243+
204244
return {
205245
role,
206-
content,
246+
content: filteredContent,
207247
}
208248
})
209249
}

src/core/task/Task.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10221022
getThoughtSignature?: () => string | undefined
10231023
getSummary?: () => any[] | undefined
10241024
getReasoningDetails?: () => any[] | undefined
1025+
getRedactedThinkingBlocks?: () => Array<{ type: "redacted_thinking"; data: string }> | undefined
10251026
}
10261027

10271028
if (message.role === "assistant") {
@@ -1072,6 +1073,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10721073
} else if (!messageWithTs.content) {
10731074
messageWithTs.content = [thinkingBlock]
10741075
}
1076+
1077+
// Also insert any redacted_thinking blocks after the thinking block.
1078+
// Anthropic returns these when safety filters trigger on reasoning content.
1079+
// They must be passed back verbatim for proper reasoning continuity.
1080+
const redactedBlocks = handler.getRedactedThinkingBlocks?.()
1081+
if (redactedBlocks && Array.isArray(messageWithTs.content)) {
1082+
// Insert after the thinking block (index 1, right after thinking at index 0)
1083+
messageWithTs.content.splice(1, 0, ...redactedBlocks)
1084+
}
10751085
} else if (reasoning && !reasoningDetails) {
10761086
// Other providers (non-Anthropic): Store as generic reasoning block
10771087
const reasoningBlock = {

0 commit comments

Comments
 (0)