Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/api/providers/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Comment on lines +1613 to +1621
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests cover getThoughtSignature(), the signature capture from stream deltas, or the new block type handling (thinking, redacted_thinking, reasoning, thoughtSignature) in bedrock-converse-format.ts. The PR description mentions 143 existing Bedrock tests passing, but none exercise these new code paths. Adding tests that verify signature capture from a mock stream and round-trip conversion of thinking blocks through convertToBedrockConverseMessages would guard against regressions.

Fix it with Roo Code or mention @roomote and request a fix.


/**
* 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 changes: 135 additions & 0 deletions src/api/transform/__tests__/bedrock-converse-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
42 changes: 41 additions & 1 deletion src/api/transform/bedrock-converse-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 10 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading