openclaw/src/agents/session-transcript-repair.ts
kiranjd c90cda7946 fix(sessions): truncate at incomplete tool calls instead of inserting synthetic results
When session history contains an assistant message with tool calls but
missing results (e.g., due to branching, interruption, or crash), the
API rejects the request. Previously, we tried to repair this by
inserting synthetic error results, but this approach was fragile and
could lead to confusing conversation states.

New approach: truncate history before the first incomplete tool call
sequence. This is simpler and always produces valid history:

- Agent continues working (just with less context)
- No confusing synthetic error results
- User may need to repeat their last request

The truncation is logged with details about which tool call IDs were
missing, helping track and diagnose the root cause.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 02:15:51 +05:30

285 lines
8.8 KiB
TypeScript

import type { AgentMessage } from "@mariozechner/pi-agent-core";
type ToolCallLike = {
id: string;
name?: string;
};
function extractToolCallsFromAssistant(
msg: Extract<AgentMessage, { role: "assistant" }>,
): ToolCallLike[] {
const content = msg.content;
if (!Array.isArray(content)) return [];
const toolCalls: ToolCallLike[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const rec = block as { type?: unknown; id?: unknown; name?: unknown };
if (typeof rec.id !== "string" || !rec.id) continue;
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
toolCalls.push({
id: rec.id,
name: typeof rec.name === "string" ? rec.name : undefined,
});
}
}
return toolCalls;
}
function extractToolResultId(msg: Extract<AgentMessage, { role: "toolResult" }>): string | null {
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
if (typeof toolCallId === "string" && toolCallId) return toolCallId;
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
if (typeof toolUseId === "string" && toolUseId) return toolUseId;
return null;
}
function makeMissingToolResult(params: {
toolCallId: string;
toolName?: string;
}): Extract<AgentMessage, { role: "toolResult" }> {
return {
role: "toolResult",
toolCallId: params.toolCallId,
toolName: params.toolName ?? "unknown",
content: [
{
type: "text",
text: "[moltbot] missing tool result in session history; inserted synthetic error result for transcript repair.",
},
],
isError: true,
timestamp: Date.now(),
} as Extract<AgentMessage, { role: "toolResult" }>;
}
export { makeMissingToolResult };
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] {
return repairToolUseResultPairing(messages).messages;
}
export type ToolUseRepairReport = {
messages: AgentMessage[];
added: Array<Extract<AgentMessage, { role: "toolResult" }>>;
droppedDuplicateCount: number;
droppedOrphanCount: number;
moved: boolean;
/** If truncation occurred, details about what was dropped. */
truncation?: {
/** Index in original messages where truncation happened. */
truncatedAtIndex: number;
/** Tool call IDs that were missing results. */
missingToolCallIds: string[];
/** Number of messages dropped. */
messagesDropped: number;
};
};
/**
* Find all tool result IDs that exist anywhere in the message array.
*/
function indexAllToolResultIds(messages: AgentMessage[]): Set<string> {
const ids = new Set<string>();
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
if ((msg as { role?: unknown }).role === "toolResult") {
const id = extractToolResultId(msg as Extract<AgentMessage, { role: "toolResult" }>);
if (id) ids.add(id);
}
}
return ids;
}
/**
* Find the first assistant message with tool calls that has ANY missing results.
* Returns the index of that assistant message, or -1 if all are complete.
*/
function findFirstIncompleteToolCallIndex(messages: AgentMessage[]): {
index: number;
missingIds: string[];
} | null {
const allResultIds = indexAllToolResultIds(messages);
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || typeof msg !== "object") continue;
if ((msg as { role?: unknown }).role !== "assistant") continue;
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) continue;
const missingIds: string[] = [];
for (const call of toolCalls) {
if (!allResultIds.has(call.id)) {
missingIds.push(call.id);
}
}
if (missingIds.length > 0) {
return { index: i, missingIds };
}
}
return null;
}
export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport {
// Anthropic (and similar APIs) reject transcripts where assistant tool calls are not
// followed by matching tool results. This can happen when:
// - Session branching separates tool calls from their results
// - Interruptions (crash, network, user edit) occur mid-tool-execution
// - Compaction/pruning breaks the pairing
//
// Strategy: TRUNCATE at the first incomplete tool call sequence.
// This is simpler and safer than trying to repair with synthetic results:
// - Always produces valid history (just shorter)
// - No confusing synthetic error results in conversation
// - Agent continues working, may just need to redo some work
//
// After truncation, we still:
// - Move displaced tool results to correct positions
// - Drop duplicate tool results
// - Drop orphaned tool results (results without matching calls)
// First pass: check if truncation is needed
const incomplete = findFirstIncompleteToolCallIndex(messages);
let workingMessages = messages;
let truncation: ToolUseRepairReport["truncation"];
if (incomplete) {
// Truncate before the incomplete assistant message
workingMessages = messages.slice(0, incomplete.index);
truncation = {
truncatedAtIndex: incomplete.index,
missingToolCallIds: incomplete.missingIds,
messagesDropped: messages.length - incomplete.index,
};
}
// Second pass: repair ordering and duplicates in the (possibly truncated) messages
const out: AgentMessage[] = [];
const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
const seenToolResultIds = new Set<string>();
let droppedDuplicateCount = 0;
let droppedOrphanCount = 0;
let moved = false;
let changed = truncation !== undefined;
const pushToolResult = (msg: Extract<AgentMessage, { role: "toolResult" }>) => {
const id = extractToolResultId(msg);
if (id && seenToolResultIds.has(id)) {
droppedDuplicateCount += 1;
changed = true;
return;
}
if (id) seenToolResultIds.add(id);
out.push(msg);
};
for (let i = 0; i < workingMessages.length; i += 1) {
const msg = workingMessages[i] as AgentMessage;
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
}
const role = (msg as { role?: unknown }).role;
if (role !== "assistant") {
// Tool results must only appear directly after the matching assistant tool call turn.
// Any "free-floating" toolResult entries can make strict providers reject the request.
if (role !== "toolResult") {
out.push(msg);
} else {
droppedOrphanCount += 1;
changed = true;
}
continue;
}
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) {
out.push(msg);
continue;
}
const toolCallIds = new Set(toolCalls.map((t) => t.id));
const spanResultsById = new Map<string, Extract<AgentMessage, { role: "toolResult" }>>();
const remainder: AgentMessage[] = [];
let j = i + 1;
for (; j < workingMessages.length; j += 1) {
const next = workingMessages[j] as AgentMessage;
if (!next || typeof next !== "object") {
remainder.push(next);
continue;
}
const nextRole = (next as { role?: unknown }).role;
if (nextRole === "assistant") break;
if (nextRole === "toolResult") {
const toolResult = next as Extract<AgentMessage, { role: "toolResult" }>;
const id = extractToolResultId(toolResult);
if (id && toolCallIds.has(id)) {
if (seenToolResultIds.has(id)) {
droppedDuplicateCount += 1;
changed = true;
continue;
}
if (!spanResultsById.has(id)) {
spanResultsById.set(id, toolResult);
}
continue;
}
}
// Drop tool results that don't match the current assistant tool calls.
if (nextRole !== "toolResult") {
remainder.push(next);
} else {
droppedOrphanCount += 1;
changed = true;
}
}
out.push(msg);
if (spanResultsById.size > 0 && remainder.length > 0) {
moved = true;
changed = true;
}
// All tool calls should have results (we truncated incomplete ones above)
for (const call of toolCalls) {
const existing = spanResultsById.get(call.id);
if (existing) {
pushToolResult(existing);
}
// No synthetic results - we truncated incomplete sequences
}
for (const rem of remainder) {
if (!rem || typeof rem !== "object") {
out.push(rem);
continue;
}
out.push(rem);
}
i = j - 1;
}
const changedOrMoved = changed || moved;
return {
messages: changedOrMoved ? out : workingMessages,
added,
droppedDuplicateCount,
droppedOrphanCount,
moved: changedOrMoved,
truncation,
};
}