Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit dfaf0e8

Browse files
committed
fix: add pre-send validation for tool_use/tool_result pairing (#11777)
Adds validateMessageHistoryBeforeSend() as a safety net that runs on the final message array right before the API call. This catches any tool_use blocks that are missing corresponding tool_result blocks - a mismatch that causes Anthropic API rejection. The validation: - Iterates through the final messages looking for assistant messages with tool_use blocks - Checks the following user message has matching tool_result blocks - Injects placeholder tool_results for any missing pairings - Inserts synthetic user messages when none follow an assistant message - Reports mismatches to telemetry via MissingToolResultError This addresses cases where post-processing steps (getEffectiveApiHistory, mergeConsecutiveApiMessages, buildCleanConversationHistory) may introduce mismatches after the existing insert-time validation.
1 parent ad25634 commit dfaf0e8

3 files changed

Lines changed: 346 additions & 2 deletions

File tree

src/core/task/Task.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHist
130130
import { MessageQueueService } from "../message-queue/MessageQueueService"
131131
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
132132
import { MessageManager } from "../message-manager"
133-
import { validateAndFixToolResultIds } from "./validateToolResultIds"
133+
import { validateAndFixToolResultIds, validateMessageHistoryBeforeSend } from "./validateToolResultIds"
134134
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
135135

136136
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
@@ -4274,10 +4274,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
42744274
// Reset the flag after using it
42754275
this.skipPrevResponseIdOnce = false
42764276

4277+
// Final safety-net: ensure every tool_use has a matching tool_result before sending.
4278+
// This catches mismatches introduced by post-processing (condensing, merging, cleaning).
4279+
const validatedHistory = validateMessageHistoryBeforeSend(
4280+
cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[],
4281+
)
4282+
42774283
// The provider accepts reasoning items alongside standard messages; cast to the expected parameter type.
42784284
const stream = this.api.createMessage(
42794285
systemPrompt,
4280-
cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[],
4286+
validatedHistory as unknown as Anthropic.Messages.MessageParam[],
42814287
metadata,
42824288
)
42834289
const iterator = stream[Symbol.asyncIterator]()

src/core/task/__tests__/validateToolResultIds.spec.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
22
import { TelemetryService } from "@roo-code/telemetry"
33
import {
44
validateAndFixToolResultIds,
5+
validateMessageHistoryBeforeSend,
56
ToolResultIdMismatchError,
67
MissingToolResultError,
78
} from "../validateToolResultIds"
@@ -995,3 +996,225 @@ describe("validateAndFixToolResultIds", () => {
995996
})
996997
})
997998
})
999+
1000+
describe("validateMessageHistoryBeforeSend", () => {
1001+
beforeEach(() => {
1002+
vi.clearAllMocks()
1003+
})
1004+
1005+
it("should return the same array reference when all tool_use blocks have matching tool_results", () => {
1006+
const messages: Anthropic.Messages.MessageParam[] = [
1007+
{
1008+
role: "user",
1009+
content: [{ type: "text", text: "Hello" }],
1010+
},
1011+
{
1012+
role: "assistant",
1013+
content: [
1014+
{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "foo.ts" } },
1015+
{ type: "tool_use", id: "tool_2", name: "read_file", input: { path: "bar.ts" } },
1016+
],
1017+
},
1018+
{
1019+
role: "user",
1020+
content: [
1021+
{ type: "tool_result", tool_use_id: "tool_1", content: "file contents 1" },
1022+
{ type: "tool_result", tool_use_id: "tool_2", content: "file contents 2" },
1023+
],
1024+
},
1025+
]
1026+
1027+
const result = validateMessageHistoryBeforeSend(messages)
1028+
expect(result).toBe(messages) // Same reference = no modification
1029+
expect(TelemetryService.instance.captureException).not.toHaveBeenCalled()
1030+
})
1031+
1032+
it("should inject placeholder tool_results for missing tool_use IDs", () => {
1033+
const messages: Anthropic.Messages.MessageParam[] = [
1034+
{
1035+
role: "user",
1036+
content: [{ type: "text", text: "Hello" }],
1037+
},
1038+
{
1039+
role: "assistant",
1040+
content: [
1041+
{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "foo.ts" } },
1042+
{ type: "tool_use", id: "tool_2", name: "read_file", input: { path: "bar.ts" } },
1043+
{ type: "tool_use", id: "tool_3", name: "read_file", input: { path: "baz.ts" } },
1044+
{ type: "tool_use", id: "tool_4", name: "read_file", input: { path: "qux.ts" } },
1045+
],
1046+
},
1047+
{
1048+
role: "user",
1049+
content: [
1050+
{ type: "tool_result", tool_use_id: "tool_1", content: "result 1" },
1051+
// tool_2, tool_3, tool_4 are missing
1052+
],
1053+
},
1054+
]
1055+
1056+
const result = validateMessageHistoryBeforeSend(messages)
1057+
1058+
// Should have the same number of messages
1059+
expect(result.length).toBe(3)
1060+
1061+
// The patched user message should contain placeholders for tool_2, tool_3, tool_4
1062+
const patchedUser = result[2]
1063+
expect(patchedUser.role).toBe("user")
1064+
const content = patchedUser.content as Anthropic.Messages.ContentBlockParam[]
1065+
1066+
// 3 placeholders + 1 existing tool_result = 4
1067+
expect(content.length).toBe(4)
1068+
1069+
const toolResults = content.filter((b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result")
1070+
expect(toolResults.length).toBe(4)
1071+
1072+
const toolResultIds = toolResults.map((r) => r.tool_use_id)
1073+
expect(toolResultIds).toContain("tool_1")
1074+
expect(toolResultIds).toContain("tool_2")
1075+
expect(toolResultIds).toContain("tool_3")
1076+
expect(toolResultIds).toContain("tool_4")
1077+
1078+
// Should report to telemetry
1079+
expect(TelemetryService.instance.captureException).toHaveBeenCalledTimes(1)
1080+
const capturedError = (TelemetryService.instance.captureException as ReturnType<typeof vi.fn>).mock.calls[0][0]
1081+
expect(capturedError).toBeInstanceOf(MissingToolResultError)
1082+
expect(capturedError.missingToolUseIds).toEqual(["tool_2", "tool_3", "tool_4"])
1083+
})
1084+
1085+
it("should insert a synthetic user message when no following user message exists", () => {
1086+
const messages: Anthropic.Messages.MessageParam[] = [
1087+
{
1088+
role: "user",
1089+
content: [{ type: "text", text: "Hello" }],
1090+
},
1091+
{
1092+
role: "assistant",
1093+
content: [{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "foo.ts" } }],
1094+
},
1095+
// No following user message
1096+
]
1097+
1098+
const result = validateMessageHistoryBeforeSend(messages)
1099+
1100+
// Should now have 3 messages (synthetic user message added)
1101+
expect(result.length).toBe(3)
1102+
expect(result[2].role).toBe("user")
1103+
1104+
const content = result[2].content as Anthropic.ToolResultBlockParam[]
1105+
expect(content.length).toBe(1)
1106+
expect(content[0].type).toBe("tool_result")
1107+
expect(content[0].tool_use_id).toBe("tool_1")
1108+
expect(content[0].content).toBe("Tool execution was interrupted before completion.")
1109+
})
1110+
1111+
it("should handle multiple assistant messages with tool_use blocks", () => {
1112+
const messages: Anthropic.Messages.MessageParam[] = [
1113+
{
1114+
role: "user",
1115+
content: [{ type: "text", text: "Hello" }],
1116+
},
1117+
{
1118+
role: "assistant",
1119+
content: [{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "foo.ts" } }],
1120+
},
1121+
{
1122+
role: "user",
1123+
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "result 1" }],
1124+
},
1125+
{
1126+
role: "assistant",
1127+
content: [
1128+
{ type: "tool_use", id: "tool_2", name: "read_file", input: { path: "bar.ts" } },
1129+
{ type: "tool_use", id: "tool_3", name: "read_file", input: { path: "baz.ts" } },
1130+
],
1131+
},
1132+
{
1133+
role: "user",
1134+
content: [
1135+
// Only tool_2 has a result, tool_3 is missing
1136+
{ type: "tool_result", tool_use_id: "tool_2", content: "result 2" },
1137+
],
1138+
},
1139+
]
1140+
1141+
const result = validateMessageHistoryBeforeSend(messages)
1142+
1143+
expect(result.length).toBe(5)
1144+
1145+
// First pair should be untouched
1146+
const firstUserContent = result[2].content as Anthropic.Messages.ContentBlockParam[]
1147+
expect(firstUserContent.length).toBe(1)
1148+
1149+
// Second pair should have placeholder for tool_3
1150+
const secondUserContent = result[4].content as Anthropic.Messages.ContentBlockParam[]
1151+
const toolResults = secondUserContent.filter(
1152+
(b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result",
1153+
)
1154+
expect(toolResults.length).toBe(2)
1155+
expect(toolResults.map((r) => r.tool_use_id).sort()).toEqual(["tool_2", "tool_3"])
1156+
})
1157+
1158+
it("should not modify messages without tool_use blocks", () => {
1159+
const messages: Anthropic.Messages.MessageParam[] = [
1160+
{
1161+
role: "user",
1162+
content: [{ type: "text", text: "Hello" }],
1163+
},
1164+
{
1165+
role: "assistant",
1166+
content: [{ type: "text", text: "Hi there!" }],
1167+
},
1168+
{
1169+
role: "user",
1170+
content: [{ type: "text", text: "Thanks" }],
1171+
},
1172+
]
1173+
1174+
const result = validateMessageHistoryBeforeSend(messages)
1175+
expect(result).toBe(messages) // Same reference
1176+
})
1177+
1178+
it("should handle assistant messages with string content", () => {
1179+
const messages: Anthropic.Messages.MessageParam[] = [
1180+
{
1181+
role: "user",
1182+
content: "Hello",
1183+
},
1184+
{
1185+
role: "assistant",
1186+
content: "Hi there!",
1187+
},
1188+
]
1189+
1190+
const result = validateMessageHistoryBeforeSend(messages)
1191+
expect(result).toBe(messages)
1192+
})
1193+
1194+
it("should handle the next message being an assistant (not user)", () => {
1195+
const messages: Anthropic.Messages.MessageParam[] = [
1196+
{
1197+
role: "user",
1198+
content: [{ type: "text", text: "Hello" }],
1199+
},
1200+
{
1201+
role: "assistant",
1202+
content: [{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "foo.ts" } }],
1203+
},
1204+
{
1205+
role: "assistant",
1206+
content: [{ type: "text", text: "Continuing..." }],
1207+
},
1208+
]
1209+
1210+
const result = validateMessageHistoryBeforeSend(messages)
1211+
1212+
// Should insert a synthetic user message between the two assistant messages
1213+
expect(result.length).toBe(4)
1214+
expect(result[2].role).toBe("user")
1215+
const syntheticContent = result[2].content as Anthropic.ToolResultBlockParam[]
1216+
expect(syntheticContent[0].tool_use_id).toBe("tool_1")
1217+
// The original second assistant message should still be there
1218+
expect(result[3].role).toBe("assistant")
1219+
})
1220+
})

src/core/task/validateToolResultIds.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,118 @@ export function validateAndFixToolResultIds(
232232
content: finalContent,
233233
}
234234
}
235+
236+
/**
237+
* Pre-send validation that ensures every tool_use block in the final message
238+
* array has a corresponding tool_result block in the immediately following
239+
* user message. This acts as a last-resort safety net right before the API
240+
* call, catching any mismatches introduced by post-processing steps like
241+
* getEffectiveApiHistory(), mergeConsecutiveApiMessages(), or
242+
* buildCleanConversationHistory().
243+
*
244+
* For any missing tool_result, a placeholder is injected so the API request
245+
* remains valid. Mismatches are reported to telemetry.
246+
*
247+
* @param messages - The final message array about to be sent to the API
248+
* @returns A new array with any missing tool_result placeholders injected
249+
*/
250+
export function validateMessageHistoryBeforeSend(
251+
messages: Anthropic.Messages.MessageParam[],
252+
): Anthropic.Messages.MessageParam[] {
253+
// Work on a shallow copy so we don't mutate the caller's array.
254+
const result: Anthropic.Messages.MessageParam[] = []
255+
let modified = false
256+
257+
for (let i = 0; i < messages.length; i++) {
258+
const current = messages[i]
259+
260+
// We only care about assistant messages that contain tool_use blocks.
261+
if (current.role !== "assistant" || !Array.isArray(current.content)) {
262+
result.push(current)
263+
continue
264+
}
265+
266+
const toolUseBlocks = (current.content as Anthropic.Messages.ContentBlockParam[]).filter(
267+
(block): block is Anthropic.ToolUseBlock => block.type === "tool_use",
268+
)
269+
270+
if (toolUseBlocks.length === 0) {
271+
result.push(current)
272+
continue
273+
}
274+
275+
result.push(current)
276+
277+
// Collect tool_use IDs that need matching tool_results.
278+
const toolUseIds = new Set(toolUseBlocks.map((b) => b.id))
279+
280+
// Look at the next message - it should be a user message with tool_results.
281+
const next = messages[i + 1]
282+
283+
// Gather existing tool_result IDs from the next message (if it's a user message).
284+
const existingToolResultIds = new Set<string>()
285+
let nextContent: Anthropic.Messages.ContentBlockParam[] = []
286+
287+
if (next && next.role === "user" && Array.isArray(next.content)) {
288+
nextContent = next.content as Anthropic.Messages.ContentBlockParam[]
289+
for (const block of nextContent) {
290+
if (block.type === "tool_result") {
291+
existingToolResultIds.add((block as Anthropic.ToolResultBlockParam).tool_use_id)
292+
}
293+
}
294+
}
295+
296+
// Determine which tool_use IDs are missing a tool_result.
297+
const missingIds = [...toolUseIds].filter((id) => !existingToolResultIds.has(id))
298+
299+
if (missingIds.length === 0) {
300+
continue // All good for this pair.
301+
}
302+
303+
// Report to telemetry.
304+
if (TelemetryService.hasInstance()) {
305+
TelemetryService.instance.captureException(
306+
new MissingToolResultError(
307+
`Pre-send validation: missing tool_result blocks for tool_use IDs: [${missingIds.join(", ")}]`,
308+
missingIds,
309+
[...existingToolResultIds],
310+
),
311+
{
312+
missingToolUseIds: missingIds,
313+
existingToolResultIds: [...existingToolResultIds],
314+
toolUseCount: toolUseBlocks.length,
315+
existingToolResultCount: existingToolResultIds.size,
316+
messageIndex: i,
317+
},
318+
)
319+
}
320+
321+
modified = true
322+
323+
// Build placeholder tool_result blocks for the missing IDs.
324+
const placeholders: Anthropic.ToolResultBlockParam[] = missingIds.map((id) => ({
325+
type: "tool_result" as const,
326+
tool_use_id: id,
327+
content: "Tool execution was interrupted before completion.",
328+
}))
329+
330+
if (next && next.role === "user") {
331+
// Inject placeholders into the existing user message.
332+
const patchedNext: Anthropic.Messages.MessageParam = {
333+
...next,
334+
content: [...placeholders, ...nextContent],
335+
}
336+
result.push(patchedNext)
337+
i++ // Skip the next message since we already pushed the patched version.
338+
} else {
339+
// No following user message at all - insert a synthetic one.
340+
result.push({
341+
role: "user" as const,
342+
content: placeholders,
343+
})
344+
// Don't skip - the next message (if any) still needs to be processed.
345+
}
346+
}
347+
348+
return modified ? result : messages
349+
}

0 commit comments

Comments
 (0)