Unverified 提交 f7b8748f authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

fix: resolve AI_MissingToolResultsError after mid-turn compaction (#3213)

When mid-turn compaction ran during a multi-step tool loop (e.g., after web_crawl completed), injected user messages (like screenshots) were registered at an array index based on the compacted message history. The AI SDK's internal messages don't include the compaction summary, so in subsequent steps the stale index caused the injected user message to land between a tool_use and its tool_result, breaking the SDK's validation. Fix 1: Track the delta between compacted and SDK base message counts, and adjust injection indices after prepareStepMessages runs so future re-injections land at the correct position. Fix 2: Add ensureToolResultOrdering() as a defensive safety net that detects and fixes any user messages misplaced between tool_use/ tool_result pairs before returning from prepareStep. <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3213" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 (1M context) <noreply@anthropic.com>
上级 ffa578d4
{ {
"name": "dyad", "name": "dyad",
"version": "0.43.0-beta.1", "version": "0.43.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.43.0-beta.1", "version": "0.43.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46", "@ai-sdk/amazon-bedrock": "^4.0.46",
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
prepareStepMessages, prepareStepMessages,
hasIncompleteTodos, hasIncompleteTodos,
buildTodoReminderMessage, buildTodoReminderMessage,
ensureToolResultOrdering,
type InjectedMessage, type InjectedMessage,
} from "@/pro/main/ipc/handlers/local_agent/prepare_step_utils"; } from "@/pro/main/ipc/handlers/local_agent/prepare_step_utils";
import type { import type {
...@@ -14,6 +15,10 @@ import type { ...@@ -14,6 +15,10 @@ import type {
} from "@/pro/main/ipc/handlers/local_agent/tools/types"; } from "@/pro/main/ipc/handlers/local_agent/tools/types";
import { ImagePart, ModelMessage } from "ai"; import { ImagePart, ModelMessage } from "ai";
function textToolResult(value: string) {
return { type: "text" as const, value };
}
describe("prepare_step_utils", () => { describe("prepare_step_utils", () => {
describe("transformContentPart", () => { describe("transformContentPart", () => {
it("transforms text parts correctly", () => { it("transforms text parts correctly", () => {
...@@ -923,4 +928,641 @@ describe("prepare_step_utils", () => { ...@@ -923,4 +928,641 @@ describe("prepare_step_utils", () => {
expect(result!.messages[2].role).toBe("assistant"); expect(result!.messages[2].role).toBe("assistant");
}); });
}); });
describe("ensureToolResultOrdering", () => {
it("returns null when ordering is already correct", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "Build a website" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "web_crawl",
input: { url: "https://example.com" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "web_crawl",
output: textToolResult("Done"),
},
],
},
{
role: "user",
content: [{ type: "text", text: "Screenshot" }],
},
];
expect(ensureToolResultOrdering(messages)).toBeNull();
});
it("returns null for messages with no tool calls", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
{ role: "user", content: "Follow up" },
];
expect(ensureToolResultOrdering(messages)).toBeNull();
});
it("moves user message past tool result when it appears between tool_use and tool_result", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "Build a website" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "web_crawl",
input: { url: "https://example.com" },
},
],
},
// User message incorrectly placed before tool result
{
role: "user",
content: [{ type: "text", text: "Screenshot" }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "web_crawl",
output: textToolResult("Done"),
},
],
},
];
const result = ensureToolResultOrdering(messages);
expect(result).not.toBeNull();
expect(result!).toHaveLength(4);
// User message should have moved after the tool result
expect(result![0].role).toBe("user");
expect(result![1].role).toBe("assistant");
expect(result![2].role).toBe("tool");
expect(result![3].role).toBe("user");
expect((result![3].content as { text: string }[])[0].text).toBe(
"Screenshot",
);
});
it("handles multiple tool calls in a single assistant message", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "Do things" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "list_files",
input: {},
},
{
type: "tool-call",
toolCallId: "call-2",
toolName: "web_crawl",
input: { url: "https://example.com" },
},
],
},
// Misplaced user message
{
role: "user",
content: [{ type: "text", text: "Screenshot" }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "list_files",
output: textToolResult("files"),
},
{
type: "tool-result",
toolCallId: "call-2",
toolName: "web_crawl",
output: textToolResult("Done"),
},
],
},
];
const result = ensureToolResultOrdering(messages);
expect(result).not.toBeNull();
expect(result!).toHaveLength(4);
expect(result![2].role).toBe("tool");
expect(result![3].role).toBe("user");
});
it("handles the exact mid-turn compaction scenario", () => {
// This reproduces the real bug: after compaction, the SDK provides
// messages without the compaction summary, and the injected screenshot
// lands between step1's tool_use and tool_result.
const messages: ModelMessage[] = [
// Original user message
{ role: "user", content: "Build LPOAC website" },
// Step 0: web_crawl + list_files
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_step0_crawl",
toolName: "web_crawl",
input: { url: "https://example.org" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_step0_crawl",
toolName: "web_crawl",
output: textToolResult("Web crawl completed."),
},
],
},
// Step 1: model generates a search_replace tool call
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_step1_replace",
toolName: "search_replace",
input: { file: "page.tsx", search: "old", replace: "new" },
},
],
},
// BUG: screenshot injected here (between step1 tool_use and tool_result)
{
role: "user",
content: [
{ type: "text", text: "Replicate the website from the screenshot" },
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_step1_replace",
toolName: "search_replace",
output: textToolResult("Replaced successfully"),
},
],
},
];
const result = ensureToolResultOrdering(messages);
expect(result).not.toBeNull();
// The screenshot should be moved after the step1 tool result
expect(result!.map((m) => m.role)).toEqual([
"user",
"assistant",
"tool",
"assistant",
"tool",
"user", // screenshot moved to end
]);
});
it("handles multiple consecutive misplaced user messages without corrupting the pending set", () => {
// Regression test: the lookahead must use a snapshot of pendingToolCallIds
// so that the first scan doesn't delete IDs needed by subsequent iterations.
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "web_crawl",
input: { url: "https://example.com" },
},
],
},
// Two consecutive misplaced user messages before the tool result
{
role: "user",
content: [{ type: "text", text: "Screenshot 1" }],
},
{
role: "user",
content: [{ type: "text", text: "Screenshot 2" }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "web_crawl",
output: textToolResult("Done"),
},
],
},
];
const result = ensureToolResultOrdering(messages);
expect(result).not.toBeNull();
// Both user messages should be moved after the tool result
expect(result!.map((m) => m.role)).toEqual([
"assistant",
"tool",
"user",
"user",
]);
// FIFO order must be preserved — Screenshot 1 before Screenshot 2
expect((result![2].content as { text: string }[])[0].text).toBe(
"Screenshot 1",
);
expect((result![3].content as { text: string }[])[0].text).toBe(
"Screenshot 2",
);
});
it("handles interleaved user messages across multiple tool results", () => {
// Regression: assistant(call-1, call-2) -> user1 -> tool(result-1) -> user2 -> tool(result-2)
// Both user messages must be moved past their respective tool results.
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "list_files",
input: {},
},
{
type: "tool-call",
toolCallId: "call-2",
toolName: "web_crawl",
input: {},
},
],
},
{
role: "user",
content: [{ type: "text", text: "Injected A" }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "list_files",
output: textToolResult("files"),
},
],
},
{
role: "user",
content: [{ type: "text", text: "Injected B" }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-2",
toolName: "web_crawl",
output: textToolResult("done"),
},
],
},
];
const result = ensureToolResultOrdering(messages);
expect(result).not.toBeNull();
// Both user messages must end up after all tool results
expect(result!.map((m) => m.role)).toEqual([
"assistant",
"tool",
"tool",
"user",
"user",
]);
// Both injected messages are present after tool results
const userTexts = result!
.filter((m) => m.role === "user")
.map((m) => (m.content as { text: string }[])[0].text);
expect(userTexts).toContain("Injected A");
expect(userTexts).toContain("Injected B");
});
it("does not move user messages across assistant turn boundaries", () => {
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "read_file",
input: {},
},
],
},
// User message between tool_use and result, but next message is
// a new assistant turn, not a tool result
{
role: "user",
content: [{ type: "text", text: "Misplaced" }],
},
{
role: "assistant",
content: [{ type: "text", text: "New turn" }],
},
];
// The function should not move the user message past the assistant boundary
// (insertAfter stays at i since no tool result was found before the next assistant)
const result = ensureToolResultOrdering(messages);
expect(result).toBeNull();
});
it("does not mutate the original array", () => {
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "test",
input: {},
},
],
},
{ role: "user", content: [{ type: "text", text: "Misplaced" }] },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "test",
output: textToolResult("ok"),
},
],
},
];
const originalRoles = messages.map((m) => m.role);
ensureToolResultOrdering(messages);
expect(messages.map((m) => m.role)).toEqual(originalRoles);
});
it("handles string content gracefully (no tool-call parts)", () => {
const messages: ModelMessage[] = [
{ role: "assistant", content: "Just text, no tool calls" },
{ role: "user", content: "Follow up" },
];
expect(ensureToolResultOrdering(messages)).toBeNull();
});
});
describe("compaction index delta adjustment", () => {
it("injection at stale index breaks ordering; adjusted index fixes it", () => {
// Simulate the compaction scenario end-to-end using the utility functions.
// --- During compaction step (step 1) ---
// Compacted messages: [user, compaction_summary, assistant(tool_use), tool(result)]
const compactedMessages: ModelMessage[] = [
{ role: "user", content: "Build LPOAC website" },
{ role: "assistant", content: "Conversation compacted." },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
input: { url: "https://example.org" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
output: textToolResult("Web crawl completed."),
},
],
},
];
const allInjectedMessages: InjectedMessage[] = [];
const pendingUserMessages: UserMessageContentPart[][] = [
[{ type: "text", text: "Screenshot from crawl" }],
];
// Process pending screenshot — index will be based on compacted length (4)
prepareStepMessages(
{ messages: compactedMessages },
pendingUserMessages,
allInjectedMessages,
);
expect(allInjectedMessages[0].insertAtIndex).toBe(4);
// --- Apply the compaction index delta ---
// preCompactionBaseCount=1 (just user_msg), postCompactionBaseCount=2 (user + summary)
const compactionIndexDelta = 2 - 1; // = 1
for (const injection of allInjectedMessages) {
injection.insertAtIndex = Math.max(
0,
injection.insertAtIndex - compactionIndexDelta,
);
}
expect(allInjectedMessages[0].insertAtIndex).toBe(3);
// --- During step 2 ---
// SDK provides messages WITHOUT the compaction summary:
// [user, step0_assistant(tool_use), step0_tool(result), step1_assistant(tool_use), step1_tool(result)]
const sdkMessages: ModelMessage[] = [
{ role: "user", content: "Build LPOAC website" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
input: { url: "https://example.org" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
output: textToolResult("Web crawl completed."),
},
],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_replace",
toolName: "search_replace",
input: {},
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_replace",
toolName: "search_replace",
output: textToolResult("ok"),
},
],
},
];
// Re-inject with adjusted index (3)
const result = injectMessagesAtPositions(
sdkMessages,
allInjectedMessages,
);
// Screenshot at index 3 = after step0_tool, before step1_assistant — correct!
expect(result.map((m) => m.role)).toEqual([
"user",
"assistant",
"tool",
"user", // screenshot at index 3
"assistant",
"tool",
]);
});
it("without delta adjustment, stale index breaks tool_use/tool_result pairing", () => {
// Prove the bug: without adjustment, the screenshot lands in the wrong spot.
const allInjectedMessages: InjectedMessage[] = [
{
insertAtIndex: 4, // Stale index from compaction step (not adjusted)
sequence: 0,
message: {
role: "user",
content: [{ type: "text", text: "Screenshot" }],
},
},
];
// SDK messages (no compaction summary)
const sdkMessages: ModelMessage[] = [
{ role: "user", content: "Build website" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
input: {},
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_crawl",
toolName: "web_crawl",
output: textToolResult("done"),
},
],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "toolu_replace",
toolName: "search_replace",
input: {},
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "toolu_replace",
toolName: "search_replace",
output: textToolResult("ok"),
},
],
},
];
const broken = injectMessagesAtPositions(
sdkMessages,
allInjectedMessages,
);
// BUG: screenshot lands between step1 assistant and step1 tool
expect(broken.map((m) => m.role)).toEqual([
"user",
"assistant",
"tool",
"assistant",
"user", // WRONG — between step1's tool_use and tool_result
"tool",
]);
// ensureToolResultOrdering fixes it as a safety net
const fixed = ensureToolResultOrdering(broken as ModelMessage[]);
expect(fixed).not.toBeNull();
expect(fixed!.map((m) => m.role)).toEqual([
"user",
"assistant",
"tool",
"assistant",
"tool",
"user", // Moved to safe position
]);
});
});
}); });
...@@ -61,6 +61,7 @@ import { ...@@ -61,6 +61,7 @@ import {
buildTodoReminderMessage, buildTodoReminderMessage,
hasIncompleteTodos, hasIncompleteTodos,
formatTodoSummary, formatTodoSummary,
ensureToolResultOrdering,
type InjectedMessage, type InjectedMessage,
} from "./prepare_step_utils"; } from "./prepare_step_utils";
import { loadTodos } from "./todo_persistence"; import { loadTodos } from "./todo_persistence";
...@@ -588,6 +589,11 @@ export async function handleLocalAgentStream( ...@@ -588,6 +589,11 @@ export async function handleLocalAgentStream(
let compactBeforeNextStep = false; let compactBeforeNextStep = false;
let compactedMidTurn = false; let compactedMidTurn = false;
let compactionFailedMidTurn = false; let compactionFailedMidTurn = false;
// Tracks the difference between the compacted base message count and the
// SDK's initialMessages count. Used to adjust injection indices after
// compaction so that subsequent steps (which use the SDK's shorter base)
// inject user messages at the correct position.
let compactionIndexDelta = 0;
const maxOutputTokens = await getMaxTokens(settings.selectedModel); const maxOutputTokens = await getMaxTokens(settings.selectedModel);
const temperature = await getTemperature(settings.selectedModel); const temperature = await getTemperature(settings.selectedModel);
...@@ -643,6 +649,7 @@ export async function handleLocalAgentStream( ...@@ -643,6 +649,7 @@ export async function handleLocalAgentStream(
compactedMidTurn = false; compactedMidTurn = false;
compactionFailedMidTurn = false; compactionFailedMidTurn = false;
compactBeforeNextStep = false; compactBeforeNextStep = false;
compactionIndexDelta = 0;
postMidTurnCompactionStartStep = null; postMidTurnCompactionStartStep = null;
baseMessageHistoryCount = currentMessageHistory.length; baseMessageHistoryCount = currentMessageHistory.length;
...@@ -741,6 +748,7 @@ export async function handleLocalAgentStream( ...@@ -741,6 +748,7 @@ export async function handleLocalAgentStream(
// with a different (typically smaller) count. Keeping them would // with a different (typically smaller) count. Keeping them would
// cause injectMessagesAtPositions to splice at wrong positions. // cause injectMessagesAtPositions to splice at wrong positions.
allInjectedMessages.length = 0; allInjectedMessages.length = 0;
const preCompactionBaseCount = baseMessageHistoryCount;
const compactedMessageHistory = buildChatMessageHistory( const compactedMessageHistory = buildChatMessageHistory(
chat.messages, chat.messages,
{ {
...@@ -750,6 +758,11 @@ export async function handleLocalAgentStream( ...@@ -750,6 +758,11 @@ export async function handleLocalAgentStream(
}, },
); );
baseMessageHistoryCount = compactedMessageHistory.length; baseMessageHistoryCount = compactedMessageHistory.length;
// The compacted history includes the compaction summary, but the
// AI SDK's initialMessages does not. Track the delta so we can
// adjust injection indices after prepareStepMessages runs.
compactionIndexDelta =
baseMessageHistoryCount - preCompactionBaseCount;
stepOptions = { stepOptions = {
...options, ...options,
// Preserve in-flight turn messages so same-turn tool loops can // Preserve in-flight turn messages so same-turn tool loops can
...@@ -771,6 +784,24 @@ export async function handleLocalAgentStream( ...@@ -771,6 +784,24 @@ export async function handleLocalAgentStream(
allInjectedMessages, allInjectedMessages,
); );
// After mid-turn compaction, injection indices are based on the
// compacted message array (which includes the compaction summary).
// The AI SDK's internal messages don't include this summary, so
// subsequent steps have a shorter base. Adjust indices now so
// future re-injections land at the correct position.
if (compactionIndexDelta !== 0) {
for (const injection of allInjectedMessages) {
injection.insertAtIndex = Math.max(
0,
injection.insertAtIndex - compactionIndexDelta,
);
}
// Always reset, even when no injections exist yet — a tool may
// add pending messages in a later step and their indices should
// not be shifted by a stale delta.
compactionIndexDelta = 0;
}
// prepareStepMessages returns undefined when it has no additional // prepareStepMessages returns undefined when it has no additional
// injections/cleanups to apply. If we already replaced the base // injections/cleanups to apply. If we already replaced the base
// message history (e.g., after mid-turn compaction), we still need // message history (e.g., after mid-turn compaction), we still need
...@@ -779,6 +810,19 @@ export async function handleLocalAgentStream( ...@@ -779,6 +810,19 @@ export async function handleLocalAgentStream(
preparedStep ?? preparedStep ??
(stepOptions === options ? undefined : stepOptions); (stepOptions === options ? undefined : stepOptions);
// Defensive: ensure injected user messages don't break
// tool_use/tool_result pairing. Catches edge cases where
// injection indices become stale after compaction.
if (result?.messages) {
const fixed = ensureToolResultOrdering(result.messages);
if (fixed) {
logger.warn(
`ensureToolResultOrdering fixed misplaced user messages in chat ${req.chatId}`,
);
result = { ...result, messages: fixed };
}
}
return result; return result;
}, },
onStepFinish: async (step) => { onStepFinish: async (step) => {
......
...@@ -193,3 +193,116 @@ export function prepareStepMessages< ...@@ -193,3 +193,116 @@ export function prepareStepMessages<
return { messages: newMessages, ...rest }; return { messages: newMessages, ...rest };
} }
/**
* Ensure user messages don't appear between a tool_use and its tool_result.
*
* After mid-turn compaction, injected user messages (e.g., web_crawl screenshots)
* can end up at stale array positions that break the AI SDK's tool result
* validation. This function detects any such misplaced user messages and moves
* them forward past the pending tool results.
*
* Returns a new array if changes were made, or null if no fix was needed.
*/
export function ensureToolResultOrdering<T extends ModelMessage>(
messages: T[],
): T[] | null {
const result = [...messages] as T[];
let changed = false;
const pendingToolCallIds = new Set<string>();
for (let i = 0; i < result.length; i++) {
const msg = result[i];
const content = Array.isArray(msg.content) ? msg.content : [];
if (msg.role === "assistant") {
for (const part of content) {
if (isToolCallPart(part)) {
pendingToolCallIds.add(part.toolCallId);
}
}
} else if (msg.role === "tool") {
for (const part of content) {
if (isToolResultPart(part)) {
pendingToolCallIds.delete(part.toolCallId);
}
}
} else if (msg.role === "user" && pendingToolCallIds.size > 0) {
// This user message is between a tool_use and its tool_result.
// Collect all consecutive misplaced user messages so we can move
// them as a batch, preserving their FIFO order.
const misplacedStart = i;
let misplacedEnd = i;
while (
misplacedEnd + 1 < result.length &&
result[misplacedEnd + 1].role === "user"
) {
misplacedEnd++;
}
const misplacedCount = misplacedEnd - misplacedStart + 1;
// Find the next position where all pending tool results are resolved.
// Use a snapshot so the lookahead doesn't corrupt the outer tracking set.
const lookaheadPending = new Set(pendingToolCallIds);
let insertAfter = misplacedEnd;
for (let j = misplacedEnd + 1; j < result.length; j++) {
const next = result[j];
if (next.role === "tool" && Array.isArray(next.content)) {
for (const part of next.content) {
if (isToolResultPart(part)) {
lookaheadPending.delete(part.toolCallId);
}
}
insertAfter = j;
if (lookaheadPending.size === 0) break;
} else if (next.role === "assistant") {
// New assistant turn — stop scanning to avoid crossing turn boundaries
break;
}
}
if (insertAfter > misplacedEnd) {
// Remove the batch and re-insert after the tool result, preserving order.
const moved = result.splice(misplacedStart, misplacedCount);
// After splice, insertAfter shifted by -misplacedCount
const adjustedTarget = insertAfter - misplacedCount + 1;
result.splice(adjustedTarget, 0, ...moved);
changed = true;
// Restart the scan from the beginning with a fresh pending set.
// The array has been mutated, so skipping ahead would miss tool-result
// messages that need to update pendingToolCallIds.
pendingToolCallIds.clear();
i = -1; // will become 0 after the for-loop increment
} else {
// Couldn't find a safe position; skip past the batch
i = misplacedEnd;
}
}
}
return changed ? result : null;
}
function isToolCallPart(
part: unknown,
): part is { type: "tool-call"; toolCallId: string } {
return (
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as Record<string, unknown>).type === "tool-call" &&
"toolCallId" in part
);
}
function isToolResultPart(
part: unknown,
): part is { type: "tool-result"; toolCallId: string } {
return (
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as Record<string, unknown>).type === "tool-result" &&
"toolCallId" in part
);
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论