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",
"version": "0.43.0-beta.1",
"version": "0.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dyad",
"version": "0.43.0-beta.1",
"version": "0.43.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46",
......
......@@ -61,6 +61,7 @@ import {
buildTodoReminderMessage,
hasIncompleteTodos,
formatTodoSummary,
ensureToolResultOrdering,
type InjectedMessage,
} from "./prepare_step_utils";
import { loadTodos } from "./todo_persistence";
......@@ -588,6 +589,11 @@ export async function handleLocalAgentStream(
let compactBeforeNextStep = false;
let compactedMidTurn = 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 temperature = await getTemperature(settings.selectedModel);
......@@ -643,6 +649,7 @@ export async function handleLocalAgentStream(
compactedMidTurn = false;
compactionFailedMidTurn = false;
compactBeforeNextStep = false;
compactionIndexDelta = 0;
postMidTurnCompactionStartStep = null;
baseMessageHistoryCount = currentMessageHistory.length;
......@@ -741,6 +748,7 @@ export async function handleLocalAgentStream(
// with a different (typically smaller) count. Keeping them would
// cause injectMessagesAtPositions to splice at wrong positions.
allInjectedMessages.length = 0;
const preCompactionBaseCount = baseMessageHistoryCount;
const compactedMessageHistory = buildChatMessageHistory(
chat.messages,
{
......@@ -750,6 +758,11 @@ export async function handleLocalAgentStream(
},
);
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 = {
...options,
// Preserve in-flight turn messages so same-turn tool loops can
......@@ -771,6 +784,24 @@ export async function handleLocalAgentStream(
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
// injections/cleanups to apply. If we already replaced the base
// message history (e.g., after mid-turn compaction), we still need
......@@ -779,6 +810,19 @@ export async function handleLocalAgentStream(
preparedStep ??
(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;
},
onStepFinish: async (step) => {
......
......@@ -193,3 +193,116 @@ export function prepareStepMessages<
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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论