fix: preserve Kimi tool call ids (#70693) (#70693)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: geri4 <2748115+geri4@users.noreply.github.com>
This commit is contained in:
Andrey Gerasimov
2026-04-24 02:34:09 +08:00
committed by GitHub
parent f7537faa21
commit 9cae47a956
3 changed files with 125 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Approvals/startup: let native approval handlers report ready after gateway authentication while replaying pending approvals in the background, so slow or failing replay delivery no longer blocks handler startup or amplifies reconnect storms.
- WhatsApp/security: keep contact/vCard/location structured-object free text out of the inline message body and render it through fenced untrusted metadata JSON, limiting hidden prompt-injection payloads in names, phone fields, and location labels/comments.
- Group-chat/security: keep channel-sourced group names and participant labels out of inline group system prompts and render them through fenced untrusted metadata JSON.
- Agents/replay: preserve Kimi-style `functions.<name>:<index>` tool-call IDs during strict replay sanitization so custom OpenAI-compatible Kimi routes keep multi-turn tool use intact. (#70693) Thanks @geri4.
- Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair.
- CLI/Claude: run the same prompt-build hooks and trigger/channel context on `claude-cli` turns as on direct embedded runs, keeping Claude Code sessions aligned with OpenClaw workspace identity, routing, and hook-driven prompt mutations. (#70625) Thanks @mbelinky.
- Discord/plugin startup: keep subagent hooks lazy behind Discord's channel entry so packaged entry imports stay narrow and report import failures with the channel id and entry path.

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import {
isValidCloudCodeAssistToolId,
sanitizeToolCallId,
sanitizeToolCallIdsForCloudCodeAssist,
} from "./tool-call-id.js";
@@ -448,6 +449,103 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
it("preserves native Kimi function ids in direct strict sanitization", () => {
expect(sanitizeToolCallId("functions.read:0", "strict")).toBe("functions.read:0");
expect(sanitizeToolCallId("functions.bash_tool:12", "strict")).toBe("functions.bash_tool:12");
expect(sanitizeToolCallId("functions.edit-file:3", "strict")).toBe("functions.edit-file:3");
expect(isValidCloudCodeAssistToolId("functions.read:0", "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId("functions.read:0", "strict9")).toBe(false);
});
it("preserves native Kimi function ids across assistant/toolResult pairs", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "functions.read:0", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "functions.read:0",
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).toBe(input);
});
it("preserves native Kimi ids while sanitizing non-Kimi siblings", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "functions.read:0", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
],
},
buildToolResult({ toolCallId: "functions.read:0", text: "native" }),
buildToolResult({ toolCallId: "call_a|b", text: "sanitized" }),
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const native = assistant.content?.[0] as { id?: string };
const sibling = assistant.content?.[1] as { id?: string };
expect(native.id).toBe("functions.read:0");
expect(sibling.id).toBe("callab");
expect((out[1] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(
"functions.read:0",
);
expect((out[2] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe("callab");
});
it("disambiguates repeated native Kimi ids after preserving the first occurrence", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "functions.read:0", name: "read", arguments: {} }],
},
buildToolResult({ toolCallId: "functions.read:0", text: "one" }),
{
role: "assistant",
content: [{ type: "toolCall", id: "functions.read:0", name: "read", arguments: {} }],
},
buildToolResult({ toolCallId: "functions.read:0", text: "two" }),
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
const first = (out[0] as Extract<AgentMessage, { role: "assistant" }>).content?.[0] as {
id?: string;
};
const second = (out[2] as Extract<AgentMessage, { role: "assistant" }>).content?.[0] as {
id?: string;
};
expect(first.id).toBe("functions.read:0");
expect(second.id).not.toBe("functions.read:0");
expect(isValidCloudCodeAssistToolId(second.id as string, "strict")).toBe(true);
expect((out[1] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(
"functions.read:0",
);
expect((out[3] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(second.id);
});
it("does not preserve malformed Kimi-like ids", () => {
for (const bad of [
"functions.read",
"functions.:0",
"functions.read:",
"functions.read:x",
"functions.read:0:extra",
"xfunctions.read:0",
]) {
expect(sanitizeToolCallId(bad, "strict")).not.toBe(bad);
expect(isValidCloudCodeAssistToolId(bad, "strict")).toBe(false);
}
});
});
describe("strict9 mode (Mistral tool call IDs)", () => {
@@ -510,5 +608,19 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(out).not.toBe(input);
expectStrict9IdLengths(expectToolUseIdsFollowDistinctToolCallIds(out, "strict9"));
});
it("rewrites native Kimi function ids in strict9 mode", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "functions.read:0", name: "read", arguments: {} }],
},
buildToolResult({ toolCallId: "functions.read:0", text: "ok" }),
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
expectSingleToolCallRewrite(out, "functions", "strict9");
});
});
});

View File

@@ -8,6 +8,7 @@ import {
export type ToolCallIdMode = "strict" | "strict9";
const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/;
const NATIVE_KIMI_TOOL_CALL_ID_RE = /^functions\.[A-Za-z0-9_-]+:\d+$/;
const STRICT9_LEN = 9;
const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
@@ -50,6 +51,10 @@ export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "strict"):
return shortHash("sanitized", STRICT9_LEN);
}
if (isNativeKimiToolCallId(id)) {
return id;
}
// Some providers require strictly alphanumeric tool call IDs.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
@@ -194,8 +199,9 @@ export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode =
if (mode === "strict9") {
return /^[a-zA-Z0-9]{9}$/.test(id);
}
// Strictly alphanumeric for providers with tighter tool ID constraints
return /^[a-zA-Z0-9]+$/.test(id);
// Strictly alphanumeric for providers with tighter tool ID constraints,
// plus native IDs we intentionally preserve for replay compatibility.
return /^[a-zA-Z0-9]+$/.test(id) || isNativeKimiToolCallId(id);
}
function shortHash(text: string, length = 8): string {
@@ -206,6 +212,10 @@ function isNativeAnthropicToolUseId(id: string): boolean {
return NATIVE_ANTHROPIC_TOOL_USE_ID_RE.test(id);
}
function isNativeKimiToolCallId(id: string): boolean {
return NATIVE_KIMI_TOOL_CALL_ID_RE.test(id);
}
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
if (params.mode === "strict9") {
const base = sanitizeToolCallId(params.id, params.mode);