mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
fix: unify tool block handling across legacy and AI SDK formats
This commit is contained in:
parent
dcb33c47ad
commit
2161f6092b
6 changed files with 427 additions and 99 deletions
|
|
@ -242,6 +242,53 @@ describe("injectSyntheticToolResults", () => {
|
|||
// Both tool_uses have matching tool_results, no injection needed
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
it("should support AI SDK tool-call/tool-result blocks", () => {
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
},
|
||||
] as any,
|
||||
ts: 2,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool-result", toolCallId: "tool-1", output: "file contents" }] as any,
|
||||
ts: 3,
|
||||
},
|
||||
]
|
||||
|
||||
const result = injectSyntheticToolResults(messages)
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
it("should inject synthetic tool_result for orphan AI SDK tool-call", () => {
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} },
|
||||
] as any,
|
||||
ts: 2,
|
||||
},
|
||||
]
|
||||
|
||||
const result = injectSyntheticToolResults(messages)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
const content = result[2].content as any[]
|
||||
expect(content).toHaveLength(1)
|
||||
expect(content[0].type).toBe("tool_result")
|
||||
expect(content[0].tool_use_id).toBe("tool-orphan")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMessagesSinceLastSummary", () => {
|
||||
|
|
@ -585,6 +632,58 @@ describe("getEffectiveApiHistory", () => {
|
|||
expect(userContent[0].type).toBe("text")
|
||||
expect(userContent[0].text).toBe("User added some text")
|
||||
})
|
||||
|
||||
it("should keep AI SDK tool-result blocks that have matching tool-call after summary", () => {
|
||||
const condenseId = "cond-1"
|
||||
const messages: ApiMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Summary content" }],
|
||||
isSummary: true,
|
||||
condenseId,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: {} }] as any,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool-result", toolCallId: "tool-valid", output: "ok" }] as any,
|
||||
},
|
||||
]
|
||||
|
||||
const result = getEffectiveApiHistory(messages)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect((result[2].content as any[])[0].toolCallId).toBe("tool-valid")
|
||||
})
|
||||
|
||||
it("should filter orphan AI SDK tool-result blocks after fresh start condensation", () => {
|
||||
const condenseId = "cond-1"
|
||||
const messages: ApiMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} },
|
||||
] as any,
|
||||
condenseParent: condenseId,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Summary content" }],
|
||||
isSummary: true,
|
||||
condenseId,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool-result", toolCallId: "tool-orphan", output: "Rejected by user" }] as any,
|
||||
},
|
||||
]
|
||||
|
||||
const result = getEffectiveApiHistory(messages)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isSummary).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cleanupAfterTruncation", () => {
|
||||
|
|
@ -1505,6 +1604,31 @@ describe("convertToolBlocksToText", () => {
|
|||
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]")
|
||||
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts")
|
||||
})
|
||||
|
||||
it("should convert AI SDK tool-call and tool-result blocks to text blocks", () => {
|
||||
const content = [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "a.ts" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "tool-1",
|
||||
output: [{ type: "text", text: "contents of a.ts" }],
|
||||
},
|
||||
] as any
|
||||
|
||||
const result = convertToolBlocksToText(content)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
const resultArray = result as Anthropic.Messages.ContentBlockParam[]
|
||||
expect(resultArray).toHaveLength(2)
|
||||
expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]")
|
||||
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]")
|
||||
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("transformMessagesForCondensing", () => {
|
||||
|
|
|
|||
|
|
@ -10,18 +10,55 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
|
|||
import { findLast } from "../../shared/array"
|
||||
import { supportPrompt } from "../../shared/support-prompt"
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
import {
|
||||
type ToolResultLikeBlock,
|
||||
type ToolUseLikeBlock,
|
||||
getToolResultLikeId,
|
||||
getToolResultLikePayload,
|
||||
getToolUseLikeId,
|
||||
getToolUseLikeName,
|
||||
isToolResultLikeBlock,
|
||||
isToolUseLikeBlock,
|
||||
stringifyUnknown,
|
||||
} from "../task/toolBlockFormat"
|
||||
import { generateFoldedFileContext } from "./foldedFileContext"
|
||||
|
||||
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
|
||||
|
||||
function toolResultPayloadToText(payload: unknown): string {
|
||||
if (typeof payload === "string") {
|
||||
return payload
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((contentBlock: unknown) => {
|
||||
if (!contentBlock || typeof contentBlock !== "object") {
|
||||
return stringifyUnknown(contentBlock)
|
||||
}
|
||||
const block = contentBlock as { type?: string; text?: string; value?: unknown }
|
||||
if (block.type === "text") {
|
||||
return typeof block.text === "string" ? block.text : stringifyUnknown(block.value)
|
||||
}
|
||||
if (block.type === "image") {
|
||||
return "[Image]"
|
||||
}
|
||||
return `[${block.type ?? "unknown"}]`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
return stringifyUnknown(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a tool_use block to a text representation.
|
||||
* This allows the conversation to be summarized without requiring the tools parameter.
|
||||
*/
|
||||
export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string {
|
||||
export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam | ToolUseLikeBlock): string {
|
||||
const toolName = getToolUseLikeName(block as ToolUseLikeBlock)
|
||||
const toolInput = (block as ToolUseLikeBlock).input
|
||||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
input = Object.entries(block.input)
|
||||
if (typeof toolInput === "object" && toolInput !== null) {
|
||||
input = Object.entries(toolInput)
|
||||
.map(([key, value]) => {
|
||||
const formattedValue =
|
||||
typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value)
|
||||
|
|
@ -29,35 +66,21 @@ export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): stri
|
|||
})
|
||||
.join("\n")
|
||||
} else {
|
||||
input = String(block.input)
|
||||
input = String(toolInput)
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
return `[Tool Use: ${toolName}]\n${input}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a tool_result block to a text representation.
|
||||
* This allows the conversation to be summarized without requiring the tools parameter.
|
||||
*/
|
||||
export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string {
|
||||
const errorSuffix = block.is_error ? " (Error)" : ""
|
||||
if (typeof block.content === "string") {
|
||||
return `[Tool Result${errorSuffix}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const contentText = block.content
|
||||
.map((contentBlock) => {
|
||||
if (contentBlock.type === "text") {
|
||||
return contentBlock.text
|
||||
}
|
||||
if (contentBlock.type === "image") {
|
||||
return "[Image]"
|
||||
}
|
||||
// Handle any other content block types
|
||||
return `[${(contentBlock as { type: string }).type}]`
|
||||
})
|
||||
.join("\n")
|
||||
return `[Tool Result${errorSuffix}]\n${contentText}`
|
||||
}
|
||||
return `[Tool Result${errorSuffix}]`
|
||||
export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam | ToolResultLikeBlock): string {
|
||||
const isError = (block as ToolResultLikeBlock).is_error || (block as ToolResultLikeBlock).isError
|
||||
const errorSuffix = isError ? " (Error)" : ""
|
||||
const payload = getToolResultLikePayload(block as ToolResultLikeBlock)
|
||||
const text = toolResultPayloadToText(payload)
|
||||
return text ? `[Tool Result${errorSuffix}]\n${text}` : `[Tool Result${errorSuffix}]`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,13 +99,13 @@ export function convertToolBlocksToText(
|
|||
}
|
||||
|
||||
return content.map((block) => {
|
||||
if (block.type === "tool_use") {
|
||||
if (isToolUseLikeBlock(block)) {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: toolUseToText(block),
|
||||
}
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
if (isToolResultLikeBlock(block)) {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: toolResultToText(block),
|
||||
|
|
@ -140,15 +163,21 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[]
|
|||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use") {
|
||||
toolCallIds.add(block.id)
|
||||
if (isToolUseLikeBlock(block)) {
|
||||
const id = getToolUseLikeId(block)
|
||||
if (id) {
|
||||
toolCallIds.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result") {
|
||||
toolResultIds.add(block.tool_use_id)
|
||||
if (isToolResultLikeBlock(block)) {
|
||||
const id = getToolResultLikeId(block)
|
||||
if (id) {
|
||||
toolResultIds.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -559,8 +588,11 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
|
|||
for (const msg of messagesFromSummary) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
|
||||
toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id)
|
||||
if (isToolUseLikeBlock(block)) {
|
||||
const id = getToolUseLikeId(block)
|
||||
if (id) {
|
||||
toolUseIds.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -571,8 +603,9 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
|
|||
.map((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
const filteredContent = msg.content.filter((block) => {
|
||||
if (block.type === "tool_result") {
|
||||
return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
|
||||
if (isToolResultLikeBlock(block)) {
|
||||
const id = getToolResultLikeId(block)
|
||||
return id ? toolUseIds.has(id) : false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
|
|
|||
|
|
@ -134,12 +134,25 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
|
|||
import { MessageManager } from "../message-manager"
|
||||
import { validateAndFixToolResultIds } from "./validateToolResultIds"
|
||||
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
|
||||
import {
|
||||
type ToolResultLikeBlock,
|
||||
getToolResultLikeId,
|
||||
getToolResultLikePayload,
|
||||
getToolUseLikeId,
|
||||
isToolResultLikeBlock,
|
||||
isToolUseLikeBlock,
|
||||
stringifyUnknown,
|
||||
} from "./toolBlockFormat"
|
||||
|
||||
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
|
||||
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
|
||||
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
|
||||
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
|
||||
|
||||
function toolResultPayloadToString(block: ToolResultLikeBlock): string {
|
||||
return stringifyUnknown(getToolResultLikePayload(block))
|
||||
}
|
||||
|
||||
export interface TaskOptions extends CreateTaskOptions {
|
||||
provider: ClineProvider
|
||||
apiConfiguration: ProviderSettings
|
||||
|
|
@ -1168,10 +1181,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
messageToAdd = {
|
||||
...message,
|
||||
content: message.content.map((block) =>
|
||||
block.type === "tool_result"
|
||||
isToolResultLikeBlock(block)
|
||||
? {
|
||||
type: "text" as const,
|
||||
text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`,
|
||||
text: `Tool result:\n${toolResultPayloadToString(block)}`,
|
||||
}
|
||||
: block,
|
||||
),
|
||||
|
|
@ -2279,17 +2292,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
const content = Array.isArray(lastMessage.content)
|
||||
? lastMessage.content
|
||||
: [{ type: "text", text: lastMessage.content }]
|
||||
const hasToolUse = content.some((block) => block.type === "tool_use")
|
||||
const hasToolUse = content.some(isToolUseLikeBlock)
|
||||
|
||||
if (hasToolUse) {
|
||||
const toolUseBlocks = content.filter(
|
||||
(block) => block.type === "tool_use",
|
||||
) as Anthropic.Messages.ToolUseBlock[]
|
||||
const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: "Task was interrupted before this tool call could be completed.",
|
||||
}))
|
||||
const toolResponses: Anthropic.ToolResultBlockParam[] = content
|
||||
.map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map((toolUseId) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content: "Task was interrupted before this tool call could be completed.",
|
||||
}))
|
||||
modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
|
||||
modifiedOldUserContent = [...toolResponses]
|
||||
} else {
|
||||
|
|
@ -2308,22 +2321,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
? previousAssistantMessage.content
|
||||
: [{ type: "text", text: previousAssistantMessage.content }]
|
||||
|
||||
const toolUseBlocks = assistantContent.filter(
|
||||
(block) => block.type === "tool_use",
|
||||
) as Anthropic.Messages.ToolUseBlock[]
|
||||
const toolUseIds = assistantContent
|
||||
.map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
if (toolUseBlocks.length > 0) {
|
||||
const existingToolResults = existingUserContent.filter(
|
||||
(block) => block.type === "tool_result",
|
||||
) as Anthropic.ToolResultBlockParam[]
|
||||
if (toolUseIds.length > 0) {
|
||||
const existingToolResultIds = new Set(
|
||||
existingUserContent
|
||||
.map((block) => (isToolResultLikeBlock(block) ? getToolResultLikeId(block) : undefined))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
)
|
||||
|
||||
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
|
||||
.filter(
|
||||
(toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id),
|
||||
)
|
||||
.map((toolUse) => ({
|
||||
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseIds
|
||||
.filter((toolUseId) => !existingToolResultIds.has(toolUseId))
|
||||
.map((toolUseId) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUse.id,
|
||||
tool_use_id: toolUseId,
|
||||
content: "Task was interrupted before this tool call could be completed.",
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,58 @@ describe("validateAndFixToolResultIds", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("AI SDK tool-call/tool-result format", () => {
|
||||
it("should keep matching toolCallId unchanged", () => {
|
||||
const assistantMessage: Anthropic.MessageParam = {
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any,
|
||||
}
|
||||
|
||||
const userMessage: Anthropic.MessageParam = {
|
||||
role: "user",
|
||||
content: [{ type: "tool-result", toolCallId: "call-1", output: "ok" }] as any,
|
||||
}
|
||||
|
||||
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
|
||||
expect(result).toEqual(userMessage)
|
||||
})
|
||||
|
||||
it("should fix mismatched toolCallId by position", () => {
|
||||
const assistantMessage: Anthropic.MessageParam = {
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", toolCallId: "call-correct", toolName: "read_file", input: {} }] as any,
|
||||
}
|
||||
|
||||
const userMessage: Anthropic.MessageParam = {
|
||||
role: "user",
|
||||
content: [{ type: "tool-result", toolCallId: "call-wrong", output: "ok" }] as any,
|
||||
}
|
||||
|
||||
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
|
||||
const resultContent = result.content as any[]
|
||||
expect(resultContent[0].type).toBe("tool-result")
|
||||
expect(resultContent[0].toolCallId).toBe("call-correct")
|
||||
})
|
||||
|
||||
it("should inject missing tool-result in AI SDK format when assistant used tool-call", () => {
|
||||
const assistantMessage: Anthropic.MessageParam = {
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any,
|
||||
}
|
||||
|
||||
const userMessage: Anthropic.MessageParam = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "No tool result yet" }],
|
||||
}
|
||||
|
||||
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
|
||||
const resultContent = result.content as any[]
|
||||
expect(resultContent[0].type).toBe("tool-result")
|
||||
expect(resultContent[0].toolCallId).toBe("call-1")
|
||||
expect(resultContent[0].output).toBe("Tool execution was interrupted before completion.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when user message has non-tool_result content", () => {
|
||||
it("should preserve text blocks alongside tool_result blocks", () => {
|
||||
const assistantMessage: Anthropic.MessageParam = {
|
||||
|
|
|
|||
67
src/core/task/toolBlockFormat.ts
Normal file
67
src/core/task/toolBlockFormat.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export type ToolUseLikeBlock = {
|
||||
type: "tool_use" | "tool-call"
|
||||
id?: string
|
||||
toolCallId?: string
|
||||
name?: string
|
||||
toolName?: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
export type ToolResultLikeBlock = {
|
||||
type: "tool_result" | "tool-result"
|
||||
tool_use_id?: string
|
||||
toolCallId?: string
|
||||
content?: unknown
|
||||
output?: unknown
|
||||
is_error?: boolean
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function isToolUseLikeBlock(block: unknown): block is ToolUseLikeBlock {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false
|
||||
}
|
||||
const maybeTool = block as ToolUseLikeBlock
|
||||
return maybeTool.type === "tool_use" || maybeTool.type === "tool-call"
|
||||
}
|
||||
|
||||
export function getToolUseLikeId(block: ToolUseLikeBlock): string | undefined {
|
||||
return block.type === "tool_use" ? block.id : block.toolCallId
|
||||
}
|
||||
|
||||
export function getToolUseLikeName(block: ToolUseLikeBlock): string {
|
||||
return block.type === "tool_use" ? (block.name ?? "unknown_tool") : (block.toolName ?? "unknown_tool")
|
||||
}
|
||||
|
||||
export function isToolResultLikeBlock(block: unknown): block is ToolResultLikeBlock {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false
|
||||
}
|
||||
const maybeToolResult = block as ToolResultLikeBlock
|
||||
return maybeToolResult.type === "tool_result" || maybeToolResult.type === "tool-result"
|
||||
}
|
||||
|
||||
export function getToolResultLikeId(block: ToolResultLikeBlock): string | undefined {
|
||||
return block.type === "tool_result" ? block.tool_use_id : block.toolCallId
|
||||
}
|
||||
|
||||
export function getToolResultLikePayload(block: ToolResultLikeBlock): unknown {
|
||||
return block.type === "tool_result" ? block.content : block.output
|
||||
}
|
||||
|
||||
export function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value)
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ""
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { findLastIndex } from "../../shared/array"
|
||||
import { getToolResultLikeId, getToolUseLikeId, isToolResultLikeBlock, isToolUseLikeBlock } from "./toolBlockFormat"
|
||||
|
||||
type AnyContentBlock = Anthropic.Messages.ContentBlockParam | (Record<string, unknown> & { type: string })
|
||||
|
||||
/**
|
||||
* Custom error class for tool result ID mismatches.
|
||||
|
|
@ -51,8 +54,10 @@ export function validateAndFixToolResultIds(
|
|||
userMessage: Anthropic.MessageParam,
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
): Anthropic.MessageParam {
|
||||
const userContent = userMessage.content as AnyContentBlock[]
|
||||
|
||||
// Only process user messages with array content
|
||||
if (userMessage.role !== "user" || !Array.isArray(userMessage.content)) {
|
||||
if (userMessage.role !== "user" || !Array.isArray(userContent)) {
|
||||
return userMessage
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ export function validateAndFixToolResultIds(
|
|||
return userMessage
|
||||
}
|
||||
|
||||
const toolUseBlocks = assistantContent.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use")
|
||||
const toolUseBlocks = (assistantContent as AnyContentBlock[]).filter(isToolUseLikeBlock)
|
||||
|
||||
// No tool_use blocks to match against - no validation needed
|
||||
if (toolUseBlocks.length === 0) {
|
||||
|
|
@ -78,9 +83,7 @@ export function validateAndFixToolResultIds(
|
|||
}
|
||||
|
||||
// Find tool_result blocks in the user message
|
||||
let toolResults = userMessage.content.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
|
||||
)
|
||||
let toolResults = userContent.filter(isToolResultLikeBlock)
|
||||
|
||||
// Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465)
|
||||
// This serves as a safety net for any potential race conditions that could generate
|
||||
|
|
@ -88,39 +91,49 @@ export function validateAndFixToolResultIds(
|
|||
// creating duplicate results) has been fixed in presentAssistantMessage.ts, but this
|
||||
// deduplication remains as a defensive measure for unknown edge cases.
|
||||
const seenToolResultIds = new Set<string>()
|
||||
const deduplicatedContent = userMessage.content.filter((block) => {
|
||||
if (block.type !== "tool_result") {
|
||||
const deduplicatedContent = userContent.filter((block) => {
|
||||
if (!isToolResultLikeBlock(block)) {
|
||||
return true
|
||||
}
|
||||
if (seenToolResultIds.has(block.tool_use_id)) {
|
||||
const id = getToolResultLikeId(block)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
if (seenToolResultIds.has(id)) {
|
||||
return false // Duplicate - filter out
|
||||
}
|
||||
seenToolResultIds.add(block.tool_use_id)
|
||||
seenToolResultIds.add(id)
|
||||
return true
|
||||
})
|
||||
|
||||
userMessage = {
|
||||
...userMessage,
|
||||
content: deduplicatedContent,
|
||||
content: deduplicatedContent as Anthropic.Messages.ContentBlockParam[],
|
||||
}
|
||||
|
||||
toolResults = deduplicatedContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
|
||||
)
|
||||
toolResults = deduplicatedContent.filter(isToolResultLikeBlock)
|
||||
|
||||
// Build a set of valid tool_use IDs
|
||||
const validToolUseIds = new Set(toolUseBlocks.map((block) => block.id))
|
||||
const validToolUseIds = new Set(
|
||||
toolUseBlocks.map((block) => getToolUseLikeId(block)).filter((id): id is string => Boolean(id)),
|
||||
)
|
||||
|
||||
// Build a set of existing tool_result IDs
|
||||
const existingToolResultIds = new Set(toolResults.map((r) => r.tool_use_id))
|
||||
const existingToolResultIds = new Set(
|
||||
toolResults.map((result) => getToolResultLikeId(result)).filter((id): id is string => Boolean(id)),
|
||||
)
|
||||
|
||||
// Check for missing tool_results (tool_use IDs that don't have corresponding tool_results)
|
||||
const missingToolUseIds = toolUseBlocks
|
||||
.filter((toolUse) => !existingToolResultIds.has(toolUse.id))
|
||||
.map((toolUse) => toolUse.id)
|
||||
.map((block) => getToolUseLikeId(block))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => !existingToolResultIds.has(id))
|
||||
|
||||
// Check if any tool_result has an invalid ID
|
||||
const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id))
|
||||
const hasInvalidIds = toolResults.some((result) => {
|
||||
const id = getToolResultLikeId(result)
|
||||
return !id || !validToolUseIds.has(id)
|
||||
})
|
||||
|
||||
// If no missing tool_results and no invalid IDs, no changes needed
|
||||
if (missingToolUseIds.length === 0 && !hasInvalidIds) {
|
||||
|
|
@ -128,8 +141,12 @@ export function validateAndFixToolResultIds(
|
|||
}
|
||||
|
||||
// We have issues - need to fix them
|
||||
const toolResultIdList = toolResults.map((r) => r.tool_use_id)
|
||||
const toolUseIdList = toolUseBlocks.map((b) => b.id)
|
||||
const toolResultIdList = toolResults
|
||||
.map((result) => getToolResultLikeId(result))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
const toolUseIdList = toolUseBlocks
|
||||
.map((toolUse) => getToolUseLikeId(toolUse))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
// Report missing tool_results to PostHog error tracking
|
||||
if (missingToolUseIds.length > 0 && TelemetryService.hasInstance()) {
|
||||
|
|
@ -167,34 +184,44 @@ export function validateAndFixToolResultIds(
|
|||
|
||||
// Match tool_results to tool_uses by position and fix incorrect IDs
|
||||
const usedToolUseIds = new Set<string>()
|
||||
const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[]
|
||||
const contentArray = userMessage.content as AnyContentBlock[]
|
||||
|
||||
const correctedContent = contentArray
|
||||
.map((block: Anthropic.Messages.ContentBlockParam) => {
|
||||
if (block.type !== "tool_result") {
|
||||
.map((block: AnyContentBlock) => {
|
||||
if (!isToolResultLikeBlock(block)) {
|
||||
return block
|
||||
}
|
||||
const currentId = getToolResultLikeId(block)
|
||||
|
||||
// If the ID is already valid and not yet used, keep it
|
||||
if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) {
|
||||
usedToolUseIds.add(block.tool_use_id)
|
||||
if (currentId && validToolUseIds.has(currentId) && !usedToolUseIds.has(currentId)) {
|
||||
usedToolUseIds.add(currentId)
|
||||
return block
|
||||
}
|
||||
|
||||
// Find which tool_result index this block is by comparing references.
|
||||
// This correctly handles duplicate tool_use_ids - we find the actual block's
|
||||
// position among all tool_results, not the first block with a matching ID.
|
||||
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
|
||||
const toolResultIndex = toolResults.indexOf(block)
|
||||
|
||||
// Try to match by position - only fix if there's a corresponding tool_use
|
||||
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
|
||||
const correctId = toolUseBlocks[toolResultIndex].id
|
||||
const correctId = getToolUseLikeId(toolUseBlocks[toolResultIndex])
|
||||
if (!correctId) {
|
||||
return null
|
||||
}
|
||||
// Only use this ID if it hasn't been used yet
|
||||
if (!usedToolUseIds.has(correctId)) {
|
||||
usedToolUseIds.add(correctId)
|
||||
if (block.type === "tool_result") {
|
||||
return {
|
||||
...block,
|
||||
tool_use_id: correctId,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...block,
|
||||
tool_use_id: correctId,
|
||||
toolCallId: correctId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -207,21 +234,33 @@ export function validateAndFixToolResultIds(
|
|||
// Add missing tool_result blocks for any tool_use that doesn't have one
|
||||
const coveredToolUseIds = new Set(
|
||||
correctedContent
|
||||
.filter(
|
||||
(b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam =>
|
||||
b.type === "tool_result",
|
||||
)
|
||||
.map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id),
|
||||
.filter(isToolResultLikeBlock)
|
||||
.map((result) => getToolResultLikeId(result))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
)
|
||||
|
||||
const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id))
|
||||
const stillMissingToolUseIds = toolUseBlocks
|
||||
.map((toolUse) => getToolUseLikeId(toolUse))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => !coveredToolUseIds.has(id))
|
||||
|
||||
// Build final content: add missing tool_results at the beginning if any
|
||||
const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolUse.id,
|
||||
content: "Tool execution was interrupted before completion.",
|
||||
}))
|
||||
const prefersAiSdkToolResultFormat =
|
||||
toolResults.some((result) => result.type === "tool-result") ||
|
||||
toolUseBlocks.some((toolUse) => toolUse.type === "tool-call")
|
||||
const missingToolResults = stillMissingToolUseIds.map((toolUseId) =>
|
||||
prefersAiSdkToolResultFormat
|
||||
? ({
|
||||
type: "tool-result" as const,
|
||||
toolCallId: toolUseId,
|
||||
output: "Tool execution was interrupted before completion.",
|
||||
} satisfies AnyContentBlock)
|
||||
: ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolUseId,
|
||||
content: "Tool execution was interrupted before completion.",
|
||||
} satisfies AnyContentBlock),
|
||||
)
|
||||
|
||||
// Insert missing tool_results at the beginning of the content array
|
||||
// This ensures they come before any text blocks that may summarize the results
|
||||
|
|
@ -229,6 +268,6 @@ export function validateAndFixToolResultIds(
|
|||
|
||||
return {
|
||||
...userMessage,
|
||||
content: finalContent,
|
||||
content: finalContent as Anthropic.Messages.ContentBlockParam[],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue