fix: sanitize malformed replay tool calls (#50005)

Merged via squash.

Prepared head SHA: 64ad5563f7ae321b749d5a52bc0b477d666dc6be
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman 2026-03-20 15:03:30 -07:00 committed by GitHub
parent cadbaa34c1
commit c3972982b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 830 additions and 25 deletions

View File

@ -187,6 +187,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp. - Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. - Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. - Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
### Breaking ### Breaking

View File

@ -16,6 +16,7 @@ import {
decodeHtmlEntitiesInObject, decodeHtmlEntitiesInObject,
wrapOllamaCompatNumCtx, wrapOllamaCompatNumCtx,
wrapStreamFnRepairMalformedToolCallArguments, wrapStreamFnRepairMalformedToolCallArguments,
wrapStreamFnSanitizeMalformedToolCalls,
wrapStreamFnTrimToolCallNames, wrapStreamFnTrimToolCallNames,
} from "./attempt.js"; } from "./attempt.js";
@ -779,6 +780,552 @@ describe("wrapStreamFnTrimToolCallNames", () => {
}); });
}); });
describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
it("drops malformed assistant tool calls from outbound context before provider replay", async () => {
const messages = [
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", name: "read", arguments: {} }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
expect(seenContext.messages).not.toBe(messages);
});
it("preserves outbound context when all assistant tool calls are valid", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toBe(messages);
});
it("preserves sessions_spawn attachment payloads on replay", async () => {
const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD";
const messages = [
{
role: "assistant",
content: [
{
type: "toolUse",
id: "call_1",
name: " SESSIONS_SPAWN ",
input: {
task: "inspect attachment",
attachments: [{ name: "snapshot.txt", content: attachmentContent }],
},
},
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["sessions_spawn"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<Record<string, unknown>> }>;
};
const toolCall = seenContext.messages[0]?.content?.[0] as {
name?: string;
input?: { attachments?: Array<{ content?: string }> };
};
expect(toolCall.name).toBe("sessions_spawn");
expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent);
});
it("preserves allowlisted tool names that contain punctuation", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "admin.export", input: { scope: "all" } }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["admin.export"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toBe(messages);
});
it("normalizes provider-prefixed replayed tool names before provider replay", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "functions.read", input: { path: "." } }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
});
it("canonicalizes mixed-case allowlisted tool names on replay", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "readfile", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["ReadFile"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("ReadFile");
});
it("recovers blank replayed tool names from their ids", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "functionswrite4", name: " ", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["write"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("write");
});
it("recovers mangled replayed tool names before dropping the call", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "functionsread3", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
});
it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", name: "read", arguments: {} }],
stopReason: "error",
},
{
role: "toolResult",
toolCallId: "call_missing",
toolName: "read",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops replayed tool calls that are no longer allowlisted", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "write", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "write",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops replayed tool names that are no longer allowlisted", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "unknown_tool", input: { path: "." } }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "unknown_tool",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([]);
});
it("drops ambiguous mangled replay names instead of guessing a tool", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "functions.exec2", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["exec", "exec2"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([]);
});
it("preserves matching tool results for retained errored assistant turns", async () => {
const messages = [
{
role: "assistant",
stopReason: "error",
content: [
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "toolCall", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "kept result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "kept result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("revalidates turn ordering after dropping an assistant replay turn", async () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "first" }],
},
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", name: "read", arguments: {} }],
},
{
role: "user",
content: [{ type: "text", text: "second" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
},
]);
});
it("drops orphaned Anthropic user tool_result blocks after replay sanitization", async () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "partial response" },
{ type: "toolUse", name: "read", input: { path: "." } },
],
},
{
role: "user",
content: [
{ type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
{ type: "text", text: "retry" },
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "partial response" }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops orphaned Anthropic user tool_result blocks after dropping an assistant replay turn", async () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "first" }],
},
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolUse", name: "read", input: { path: "." } }],
},
{
role: "user",
content: [
{ type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
{ type: "text", text: "second" },
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
},
]);
});
});
describe("wrapStreamFnRepairMalformedToolCallArguments", () => { describe("wrapStreamFnRepairMalformedToolCallArguments", () => {
async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) { async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) {
return await invokeWrappedTestStream( return await invokeWrappedTestStream(

View File

@ -97,6 +97,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
import { normalizeToolName } from "../../tool-policy.js"; import { normalizeToolName } from "../../tool-policy.js";
import type { TranscriptPolicy } from "../../transcript-policy.js";
import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { isRunnerAbortError } from "../abort.js"; import { isRunnerAbortError } from "../abort.js";
@ -648,6 +649,200 @@ function isToolCallBlockType(type: unknown): boolean {
return type === "toolCall" || type === "toolUse" || type === "functionCall"; return type === "toolCall" || type === "toolUse" || type === "functionCall";
} }
const REPLAY_TOOL_CALL_NAME_MAX_CHARS = 64;
type ReplayToolCallBlock = {
type?: unknown;
id?: unknown;
name?: unknown;
input?: unknown;
arguments?: unknown;
};
type ReplayToolCallSanitizeReport = {
messages: AgentMessage[];
droppedAssistantMessages: number;
};
type AnthropicToolResultContentBlock = {
type?: unknown;
toolUseId?: unknown;
};
function isReplayToolCallBlock(block: unknown): block is ReplayToolCallBlock {
if (!block || typeof block !== "object") {
return false;
}
return isToolCallBlockType((block as { type?: unknown }).type);
}
function replayToolCallHasInput(block: ReplayToolCallBlock): boolean {
const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
const hasArguments =
"arguments" in block ? block.arguments !== undefined && block.arguments !== null : false;
return hasInput || hasArguments;
}
function replayToolCallNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function resolveReplayToolCallName(
rawName: string,
rawId: string,
allowedToolNames?: Set<string>,
): string | null {
if (rawName.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS * 2) {
return null;
}
const normalized = normalizeToolCallNameForDispatch(rawName, allowedToolNames, rawId);
const trimmed = normalized.trim();
if (!trimmed || trimmed.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS || /\s/.test(trimmed)) {
return null;
}
if (!allowedToolNames || allowedToolNames.size === 0) {
return trimmed;
}
return resolveExactAllowedToolName(trimmed, allowedToolNames);
}
function sanitizeReplayToolCallInputs(
messages: AgentMessage[],
allowedToolNames?: Set<string>,
): ReplayToolCallSanitizeReport {
let changed = false;
let droppedAssistantMessages = 0;
const out: AgentMessage[] = [];
for (const message of messages) {
if (!message || typeof message !== "object" || message.role !== "assistant") {
out.push(message);
continue;
}
if (!Array.isArray(message.content)) {
out.push(message);
continue;
}
const nextContent: typeof message.content = [];
let messageChanged = false;
for (const block of message.content) {
if (!isReplayToolCallBlock(block)) {
nextContent.push(block);
continue;
}
const replayBlock = block as ReplayToolCallBlock;
if (!replayToolCallHasInput(replayBlock) || !replayToolCallNonEmptyString(replayBlock.id)) {
changed = true;
messageChanged = true;
continue;
}
const rawName = typeof replayBlock.name === "string" ? replayBlock.name : "";
const resolvedName = resolveReplayToolCallName(rawName, replayBlock.id, allowedToolNames);
if (!resolvedName) {
changed = true;
messageChanged = true;
continue;
}
if (replayBlock.name !== resolvedName) {
nextContent.push({ ...(block as object), name: resolvedName } as typeof block);
changed = true;
messageChanged = true;
continue;
}
nextContent.push(block);
}
if (messageChanged) {
changed = true;
if (nextContent.length > 0) {
out.push({ ...message, content: nextContent });
} else {
droppedAssistantMessages += 1;
}
continue;
}
out.push(message);
}
return {
messages: changed ? out : messages,
droppedAssistantMessages,
};
}
function sanitizeAnthropicReplayToolResults(messages: AgentMessage[]): AgentMessage[] {
let changed = false;
const out: AgentMessage[] = [];
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (!message || typeof message !== "object" || message.role !== "user") {
out.push(message);
continue;
}
if (!Array.isArray(message.content)) {
out.push(message);
continue;
}
const previous = messages[index - 1];
const validToolUseIds = new Set<string>();
if (previous && typeof previous === "object" && previous.role === "assistant") {
const previousContent = (previous as { content?: unknown }).content;
if (Array.isArray(previousContent)) {
for (const block of previousContent) {
if (!block || typeof block !== "object") {
continue;
}
const typedBlock = block as { type?: unknown; id?: unknown };
if (typedBlock.type !== "toolUse" || typeof typedBlock.id !== "string") {
continue;
}
const trimmedId = typedBlock.id.trim();
if (trimmedId) {
validToolUseIds.add(trimmedId);
}
}
}
}
const nextContent = message.content.filter((block) => {
if (!block || typeof block !== "object") {
return true;
}
const typedBlock = block as AnthropicToolResultContentBlock;
if (typedBlock.type !== "toolResult" || typeof typedBlock.toolUseId !== "string") {
return true;
}
return validToolUseIds.size > 0 && validToolUseIds.has(typedBlock.toolUseId);
});
if (nextContent.length === message.content.length) {
out.push(message);
continue;
}
changed = true;
if (nextContent.length > 0) {
out.push({ ...message, content: nextContent });
continue;
}
out.push({
...message,
content: [{ type: "text", text: "[tool results omitted]" }],
} as AgentMessage);
}
return changed ? out : messages;
}
function normalizeToolCallIdsInMessage(message: unknown): void { function normalizeToolCallIdsInMessage(message: unknown): void {
if (!message || typeof message !== "object") { if (!message || typeof message !== "object") {
return; return;
@ -796,6 +991,43 @@ export function wrapStreamFnTrimToolCallNames(
}; };
} }
export function wrapStreamFnSanitizeMalformedToolCalls(
baseFn: StreamFn,
allowedToolNames?: Set<string>,
transcriptPolicy?: Pick<TranscriptPolicy, "validateGeminiTurns" | "validateAnthropicTurns">,
): StreamFn {
return (model, context, options) => {
const ctx = context as unknown as { messages?: unknown };
const messages = ctx?.messages;
if (!Array.isArray(messages)) {
return baseFn(model, context, options);
}
const sanitized = sanitizeReplayToolCallInputs(messages as AgentMessage[], allowedToolNames);
if (sanitized.messages === messages) {
return baseFn(model, context, options);
}
let nextMessages = sanitizeToolUseResultPairing(sanitized.messages, {
preserveErroredAssistantResults: true,
});
if (transcriptPolicy?.validateAnthropicTurns) {
nextMessages = sanitizeAnthropicReplayToolResults(nextMessages);
}
if (sanitized.droppedAssistantMessages > 0 || transcriptPolicy?.validateAnthropicTurns) {
if (transcriptPolicy?.validateGeminiTurns) {
nextMessages = validateGeminiTurns(nextMessages);
}
if (transcriptPolicy?.validateAnthropicTurns) {
nextMessages = validateAnthropicTurns(nextMessages);
}
}
const nextContext = {
...(context as unknown as Record<string, unknown>),
messages: nextMessages,
} as unknown;
return baseFn(model, nextContext as typeof context, options);
};
}
function extractBalancedJsonPrefix(raw: string): string | null { function extractBalancedJsonPrefix(raw: string): string | null {
let start = 0; let start = 0;
while (start < raw.length && /\s/.test(raw[start] ?? "")) { while (start < raw.length && /\s/.test(raw[start] ?? "")) {
@ -2100,6 +2332,11 @@ export async function runEmbeddedAttempt(
// Some models emit tool names with surrounding whitespace (e.g. " read "). // Some models emit tool names with surrounding whitespace (e.g. " read ").
// pi-agent-core dispatches tool calls with exact string matching, so normalize // pi-agent-core dispatches tool calls with exact string matching, so normalize
// names on the live response stream before tool execution. // names on the live response stream before tool execution.
activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls(
activeSession.agent.streamFn,
allowedToolNames,
transcriptPolicy,
);
activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames( activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames(
activeSession.agent.streamFn, activeSession.agent.streamFn,
allowedToolNames, allowedToolNames,

View File

@ -195,6 +195,10 @@ export type ToolCallInputRepairOptions = {
allowedToolNames?: Iterable<string>; allowedToolNames?: Iterable<string>;
}; };
export type ToolUseResultPairingOptions = {
preserveErroredAssistantResults?: boolean;
};
export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] { export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] {
let touched = false; let touched = false;
const out: AgentMessage[] = []; const out: AgentMessage[] = [];
@ -327,8 +331,11 @@ export function sanitizeToolCallInputs(
return repairToolCallInputs(messages, options).messages; return repairToolCallInputs(messages, options).messages;
} }
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { export function sanitizeToolUseResultPairing(
return repairToolUseResultPairing(messages).messages; messages: AgentMessage[],
options?: ToolUseResultPairingOptions,
): AgentMessage[] {
return repairToolUseResultPairing(messages, options).messages;
} }
export type ToolUseRepairReport = { export type ToolUseRepairReport = {
@ -339,7 +346,10 @@ export type ToolUseRepairReport = {
moved: boolean; moved: boolean;
}; };
export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport { export function repairToolUseResultPairing(
messages: AgentMessage[],
options?: ToolUseResultPairingOptions,
): ToolUseRepairReport {
// Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not // Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
// immediately followed by matching tool results. Session files can end up with results // immediately followed by matching tool results. Session files can end up with results
// displaced (e.g. after user turns) or duplicated. Repair by: // displaced (e.g. after user turns) or duplicated. Repair by:
@ -390,18 +400,6 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>; const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
// Skip tool call extraction for aborted or errored assistant messages.
// When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
// (e.g., partialJson: true) and should not have synthetic tool_results created.
// Creating synthetic results for incomplete tool calls causes API 400 errors:
// "unexpected tool_use_id found in tool_result blocks"
// See: https://github.com/openclaw/openclaw/issues/4597
const stopReason = (assistant as { stopReason?: string }).stopReason;
if (stopReason === "error" || stopReason === "aborted") {
out.push(msg);
continue;
}
const toolCalls = extractToolCallsFromAssistant(assistant); const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
out.push(msg); out.push(msg);
@ -459,6 +457,28 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
} }
} }
// Aborted/errored assistant turns should never synthesize missing tool results, but
// the replay sanitizer can still legitimately retain real tool results for surviving
// tool calls in the same turn after malformed siblings are dropped.
const stopReason = (assistant as { stopReason?: string }).stopReason;
if (stopReason === "error" || stopReason === "aborted") {
out.push(msg);
if (options?.preserveErroredAssistantResults) {
for (const toolCall of toolCalls) {
const result = spanResultsById.get(toolCall.id);
if (!result) {
continue;
}
pushToolResult(result);
}
}
for (const rem of remainder) {
out.push(rem);
}
i = j - 1;
continue;
}
out.push(msg); out.push(msg);
if (spanResultsById.size > 0 && remainder.length > 0) { if (spanResultsById.size > 0 && remainder.length > 0) {

View File

@ -1,11 +1,11 @@
import bravePlugin from "../../extensions/brave/index.js"; import bravePlugin from "../extensions/brave/index.js";
import firecrawlPlugin from "../../extensions/firecrawl/index.js"; import firecrawlPlugin from "../extensions/firecrawl/index.js";
import googlePlugin from "../../extensions/google/index.js"; import googlePlugin from "../extensions/google/index.js";
import moonshotPlugin from "../../extensions/moonshot/index.js"; import moonshotPlugin from "../extensions/moonshot/index.js";
import perplexityPlugin from "../../extensions/perplexity/index.js"; import perplexityPlugin from "../extensions/perplexity/index.js";
import tavilyPlugin from "../../extensions/tavily/index.js"; import tavilyPlugin from "../extensions/tavily/index.js";
import xaiPlugin from "../../extensions/xai/index.js"; import xaiPlugin from "../extensions/xai/index.js";
import type { OpenClawPluginApi } from "./types.js"; import type { OpenClawPluginApi } from "./plugins/types.js";
type RegistrablePlugin = { type RegistrablePlugin = {
id: string; id: string;

View File

@ -1,4 +1,4 @@
import { bundledWebSearchPluginRegistrations } from "./bundled-web-search-registry.js"; import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js";
import { capturePluginRegistration } from "./captured-registration.js"; import { capturePluginRegistration } from "./captured-registration.js";
import type { PluginLoadOptions } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js";

View File

@ -34,7 +34,7 @@ import volcenginePlugin from "../../../extensions/volcengine/index.js";
import xaiPlugin from "../../../extensions/xai/index.js"; import xaiPlugin from "../../../extensions/xai/index.js";
import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
import zaiPlugin from "../../../extensions/zai/index.js"; import zaiPlugin from "../../../extensions/zai/index.js";
import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import { bundledWebSearchPluginRegistrations } from "../../bundled-web-search-registry.js";
import { createCapturedPluginRegistration } from "../captured-registration.js"; import { createCapturedPluginRegistration } from "../captured-registration.js";
import { resolvePluginProviders } from "../providers.js"; import { resolvePluginProviders } from "../providers.js";
import type { import type {