fix(moonshot): rewrite duplicate native Kimi tool call ids

Preserve the first native Kimi tool-call ID while rewriting repeated replay occurrences to deterministic OpenAI-style IDs and keeping paired tool results aligned. Moonshot responses-family behavior and providers that do not opt in remain unchanged.

Closes #51593

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
This commit is contained in:
Rain
2026-06-13 12:14:03 +08:00
committed by GitHub
parent 68222ba5e3
commit 4a6666796f
16 changed files with 156 additions and 16 deletions
+1 -1
View File
@@ -373,7 +373,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
</Accordion>
<Accordion title="Tool call id sanitization">
Moonshot Kimi serves tool_call ids shaped like `functions.<name>:<index>`. OpenClaw preserves them unchanged so multi-turn tool calls keep working.
Moonshot Kimi serves native tool_call ids shaped like `functions.<name>:<index>`. For the OpenAI-completions transport, OpenClaw preserves the first occurrence of each native Kimi id and rewrites later duplicates to deterministic OpenAI-style `call_*` ids. Matching tool results are remapped with the same id so replay remains unique without stripping Kimi's first native id.
To force strict sanitization on a custom OpenAI-compatible provider, set `sanitizeToolCallIds: true`:
+21 -3
View File
@@ -44,7 +44,7 @@ describe("moonshot provider plugin", () => {
});
});
it("owns replay policy for OpenAI-compatible Moonshot transports without mangling native Kimi tool_call IDs", async () => {
it("rewrites duplicate tool-call ids with OpenAI-style ids for Moonshot replay", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const policy = provider.buildReplayPolicy?.({
@@ -57,10 +57,28 @@ describe("moonshot provider plugin", () => {
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
duplicateToolCallIdStyle: "openai",
});
expect(policy).not.toHaveProperty("dropReasoningFromHistory");
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("preserves responses-family replay behavior", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const policy = provider.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-responses",
modelId: "kimi-k2.6",
} as never);
expect(policy).toEqual({
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
allowSyntheticToolResults: true,
});
});
it("wires moonshot-thinking stream hooks", async () => {
+8 -9
View File
@@ -1,6 +1,6 @@
// Moonshot plugin entrypoint registers its OpenClaw integration.
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared";
import { MOONSHOT_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
import { applyMoonshotNativeStreamingUsageCompat } from "./api.js";
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
@@ -61,14 +61,13 @@ export default defineSingleProviderPluginEntry({
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyMoonshotNativeStreamingUsageCompat(providerConfig),
// Kimi K2+ returns native tool_call IDs shaped like `functions.<name>:<index>`.
// Sanitizing them to alphanumeric-only breaks Kimi's serving-layer matching in
// multi-turn replay. See openclaw/openclaw#62319.
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
sanitizeToolCallIds: false,
dropReasoningFromHistory: false,
}),
buildReplayPolicy: ({ modelApi, modelId }) =>
buildOpenAICompatibleReplayPolicy(modelApi, {
modelId,
sanitizeToolCallIds: modelApi === "openai-completions",
duplicateToolCallIdStyle: "openai",
dropReasoningFromHistory: false,
}),
...moonshotThinkingStreamHooks,
wrapSimpleCompletionStreamFn: (ctx) =>
ctx.modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID
@@ -62,6 +62,7 @@ export async function sanitizeSessionMessagesImages(
sanitizeMode?: "full" | "images-only";
sanitizeToolCallIds?: boolean;
preserveNativeAnthropicToolUseIds?: boolean;
duplicateToolCallIdStyle?: "openai";
/**
* Mode for tool call ID sanitization:
* - "strict" (alphanumeric only)
@@ -87,6 +88,7 @@ export async function sanitizeSessionMessagesImages(
const sanitizedIds = shouldSanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode, {
preserveNativeAnthropicToolUseIds: options?.preserveNativeAnthropicToolUseIds,
duplicateToolCallIdStyle: options?.duplicateToolCallIdStyle,
})
: messages;
const out: AgentMessage[] = [];
@@ -705,6 +705,7 @@ export async function sanitizeSessionHistory(params: {
sanitizeToolCallIds:
policy.sanitizeToolCallIds && !allowProviderOwnedThinkingReplay && !isOpenAIResponsesApi,
toolCallIdMode: policy.toolCallIdMode,
duplicateToolCallIdStyle: policy.duplicateToolCallIdStyle,
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
preserveSignatures: policy.preserveSignatures,
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
@@ -769,6 +770,7 @@ export async function sanitizeSessionHistory(params: {
policy.sanitizeToolCallIds && policy.toolCallIdMode
? sanitizeToolCallIdsForCloudCodeAssist(openAISafeToolCalls, policy.toolCallIdMode, {
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
duplicateToolCallIdStyle: policy.duplicateToolCallIdStyle,
preserveReplaySafeThinkingToolCallIds: allowProviderOwnedThinkingReplay,
allowedToolNames: params.allowedToolNames,
})
@@ -1159,11 +1159,13 @@ export function sanitizeReplayToolCallIdsForStream(params: {
mode: ToolCallIdMode;
allowedToolNames?: Set<string>;
preserveNativeAnthropicToolUseIds?: boolean;
duplicateToolCallIdStyle?: "openai";
preserveReplaySafeThinkingToolCallIds?: boolean;
repairToolUseResultPairing?: boolean;
}): AgentMessage[] {
const sanitized = sanitizeToolCallIdsForCloudCodeAssist(params.messages, params.mode, {
preserveNativeAnthropicToolUseIds: params.preserveNativeAnthropicToolUseIds,
duplicateToolCallIdStyle: params.duplicateToolCallIdStyle,
preserveReplaySafeThinkingToolCallIds: params.preserveReplaySafeThinkingToolCallIds,
allowedToolNames: params.allowedToolNames,
});
@@ -2794,6 +2794,7 @@ export async function runEmbeddedAttempt(
mode,
allowedToolNames: replayAllowedToolNames,
preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds,
duplicateToolCallIdStyle: transcriptPolicy.duplicateToolCallIdStyle,
preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({
modelApi: (model as { api?: unknown })?.api as string | null | undefined,
provider: params.provider,
+1
View File
@@ -299,6 +299,7 @@ export type AgentRuntimeTranscriptPolicy = {
sanitizeMode: "full" | "images-only";
sanitizeToolCallIds: boolean;
toolCallIdMode?: AgentRuntimeToolCallIdMode;
duplicateToolCallIdStyle?: "openai";
preserveNativeAnthropicToolUseIds: boolean;
repairToolUseResultPairing: boolean;
preserveSignatures: boolean;
+30
View File
@@ -536,6 +536,36 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect((out[3] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(second.id);
});
it("uses OpenAI-style ids for repeated native Kimi ids when requested", () => {
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 options = { duplicateToolCallIdStyle: "openai" as const };
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict", options);
const firstContent = (out[0] as Extract<AgentMessage, { role: "assistant" }>).content;
const secondContent = (out[2] as Extract<AgentMessage, { role: "assistant" }>).content;
if (!Array.isArray(firstContent) || !Array.isArray(secondContent)) {
throw new Error("Expected assistant tool-call content");
}
const firstId = (firstContent[0] as { id?: string }).id;
const secondId = (secondContent[0] as { id?: string }).id;
expect(firstId).toBe("functions.read:0");
expect(secondId).toMatch(/^call_[a-f0-9]{24}$/);
expect((out[1] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(firstId);
expect((out[3] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(secondId);
expect(sanitizeToolCallIdsForCloudCodeAssist(out, "strict", options)).toBe(out);
});
it("does not preserve malformed Kimi-like ids", () => {
for (const bad of [
"functions.read",
+28 -1
View File
@@ -10,6 +10,7 @@ import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-sh
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 OPENAI_TOOL_CALL_ID_RE = /^call_[A-Za-z0-9_-]+$/;
const STRICT9_LEN = 9;
const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
@@ -286,6 +287,7 @@ function createOccurrenceAwareResolver(
mode: ToolCallIdMode,
options?: {
preserveNativeAnthropicToolUseIds?: boolean;
duplicateToolCallIdStyle?: "openai";
reservedIds?: Iterable<string>;
},
): {
@@ -298,6 +300,7 @@ function createOccurrenceAwareResolver(
const orphanToolResultOccurrences = new Map<string, number>();
const pendingByRawId = new Map<string, string[]>();
const preserveNativeAnthropicToolUseIds = options?.preserveNativeAnthropicToolUseIds === true;
const duplicateToolCallIdStyle = options?.duplicateToolCallIdStyle;
const allocate = (seed: string): string => {
const next = makeUniqueToolId({ id: seed, used, mode });
@@ -305,7 +308,26 @@ function createOccurrenceAwareResolver(
return next;
};
const allocateOpenAIStyleId = (id: string, occurrence: number): string => {
for (let attempt = 0; ; attempt += 1) {
const candidate = `call_${shortHash(`${id}:${occurrence}:${attempt}`, 24)}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
}
};
const allocatePreservingNativeAnthropicId = (id: string, occurrence: number): string => {
if (
duplicateToolCallIdStyle === "openai" &&
occurrence === 1 &&
OPENAI_TOOL_CALL_ID_RE.test(id) &&
!used.has(id)
) {
used.add(id);
return id;
}
if (
preserveNativeAnthropicToolUseIds &&
isNativeAnthropicToolUseId(id) &&
@@ -321,7 +343,10 @@ function createOccurrenceAwareResolver(
const resolveAssistantId = (id: string): string => {
const occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
assistantOccurrences.set(id, occurrence);
const next = allocatePreservingNativeAnthropicId(id, occurrence);
const next =
duplicateToolCallIdStyle === "openai" && occurrence > 1
? allocateOpenAIStyleId(id, occurrence)
: allocatePreservingNativeAnthropicId(id, occurrence);
const pending = pendingByRawId.get(id);
if (pending) {
pending.push(next);
@@ -442,12 +467,14 @@ function rewriteToolResultIds(params: {
*
* @param messages - The messages to sanitize
* @param mode - "strict" (alphanumeric only) or "strict9" (alphanumeric length 9)
* @param options.duplicateToolCallIdStyle - Optional provider-safe style for repeated IDs
*/
export function sanitizeToolCallIdsForCloudCodeAssist(
messages: AgentMessage[],
mode: ToolCallIdMode = "strict",
options?: {
preserveNativeAnthropicToolUseIds?: boolean;
duplicateToolCallIdStyle?: "openai";
preserveReplaySafeThinkingToolCallIds?: boolean;
allowedToolNames?: Iterable<string>;
},
+30 -1
View File
@@ -15,7 +15,15 @@ vi.mock("../plugins/provider-hook-runtime.js", () => ({
toolCallIdMode: "strict9",
}),
}
: undefined,
: provider === "moonshot"
? {
buildReplayPolicy: () => ({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
duplicateToolCallIdStyle: "openai",
}),
}
: undefined,
),
}));
@@ -27,6 +35,14 @@ const MISTRAL_PLUGIN_CONFIG = {
},
} as OpenClawConfig;
const MOONSHOT_PLUGIN_CONFIG = {
plugins: {
entries: {
moonshot: { enabled: true },
},
},
} as OpenClawConfig;
function createProviderRuntimeSmokeContext(): {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -68,4 +84,17 @@ describe("resolveTranscriptPolicy provider replay policy", () => {
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict9");
});
it("uses OpenAI-style duplicate ids for Moonshot replay", () => {
const policy = resolveTranscriptPolicy({
...createProviderRuntimeSmokeContext(),
provider: "moonshot",
modelId: "kimi-k2.6",
modelApi: "openai-completions",
config: MOONSHOT_PLUGIN_CONFIG,
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.duplicateToolCallIdStyle).toBe("openai");
});
});
+5
View File
@@ -23,6 +23,7 @@ export type TranscriptPolicy = {
sanitizeMode: TranscriptSanitizeMode;
sanitizeToolCallIds: boolean;
toolCallIdMode?: ToolCallIdMode;
duplicateToolCallIdStyle?: "openai";
preserveNativeAnthropicToolUseIds: boolean;
repairToolUseResultPairing: boolean;
preserveSignatures: boolean;
@@ -69,6 +70,7 @@ const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = {
sanitizeMode: "images-only",
sanitizeToolCallIds: false,
toolCallIdMode: undefined,
duplicateToolCallIdStyle: undefined,
preserveNativeAnthropicToolUseIds: false,
repairToolUseResultPairing: true,
preserveSignatures: false,
@@ -226,6 +228,9 @@ function mergeTranscriptPolicy(
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
: {}),
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
...(policy.duplicateToolCallIdStyle
? { duplicateToolCallIdStyle: policy.duplicateToolCallIdStyle }
: {}),
...(typeof policy.preserveNativeAnthropicToolUseIds === "boolean"
? { preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds }
: {}),
+3
View File
@@ -174,6 +174,8 @@ type BuildProviderReplayFamilyHooksOptions =
family: "openai-compatible";
/** Whether replay policy should rewrite tool call ids for provider compatibility. */
sanitizeToolCallIds?: boolean;
/** Optional output style for repeated tool call ids. */
duplicateToolCallIdStyle?: "openai";
/** Whether replay policy should strip reasoning blocks from history. */
dropReasoningFromHistory?: boolean;
}
@@ -210,6 +212,7 @@ export function buildProviderReplayFamilyHooks(
case "openai-compatible": {
const policyOptions = {
sanitizeToolCallIds: options.sanitizeToolCallIds,
duplicateToolCallIdStyle: options.duplicateToolCallIdStyle,
dropReasoningFromHistory: options.dropReasoningFromHistory,
};
return {
@@ -46,6 +46,19 @@ describe("provider replay helpers", () => {
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("selects OpenAI-style ids for duplicate replay tool calls", () => {
expectFields(
buildOpenAICompatibleReplayPolicy("openai-completions", {
duplicateToolCallIdStyle: "openai",
}),
{
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
duplicateToolCallIdStyle: "openai",
},
);
});
it("drops historical reasoning for OpenAI-compatible chat completions replay", () => {
expect(
buildOpenAICompatibleReplayPolicy("openai-completions", {
+8 -1
View File
@@ -15,6 +15,7 @@ export function buildOpenAICompatibleReplayPolicy(
modelApi: string | null | undefined,
options: {
sanitizeToolCallIds?: boolean;
duplicateToolCallIdStyle?: "openai";
modelId?: string | null;
dropReasoningFromHistory?: boolean;
} = {},
@@ -37,7 +38,13 @@ export function buildOpenAICompatibleReplayPolicy(
return {
...(sanitizeToolCallIds
? { sanitizeToolCallIds: true, toolCallIdMode: "strict" as const }
? {
sanitizeToolCallIds: true,
toolCallIdMode: "strict" as const,
...(options.duplicateToolCallIdStyle
? { duplicateToolCallIdStyle: options.duplicateToolCallIdStyle }
: {}),
}
: {}),
...(isResponsesFamily ? { allowSyntheticToolResults: true } : {}),
...(modelApi === "openai-completions"
+1
View File
@@ -790,6 +790,7 @@ export type ProviderReplayPolicy = {
sanitizeMode?: ProviderReplaySanitizeMode;
sanitizeToolCallIds?: boolean;
toolCallIdMode?: ProviderReplayToolCallIdMode;
duplicateToolCallIdStyle?: "openai";
preserveNativeAnthropicToolUseIds?: boolean;
preserveSignatures?: boolean;
sanitizeThoughtSignatures?: {