mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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`:
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -299,6 +299,7 @@ export type AgentRuntimeTranscriptPolicy = {
|
||||
sanitizeMode: "full" | "images-only";
|
||||
sanitizeToolCallIds: boolean;
|
||||
toolCallIdMode?: AgentRuntimeToolCallIdMode;
|
||||
duplicateToolCallIdStyle?: "openai";
|
||||
preserveNativeAnthropicToolUseIds: boolean;
|
||||
repairToolUseResultPairing: boolean;
|
||||
preserveSignatures: boolean;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -790,6 +790,7 @@ export type ProviderReplayPolicy = {
|
||||
sanitizeMode?: ProviderReplaySanitizeMode;
|
||||
sanitizeToolCallIds?: boolean;
|
||||
toolCallIdMode?: ProviderReplayToolCallIdMode;
|
||||
duplicateToolCallIdStyle?: "openai";
|
||||
preserveNativeAnthropicToolUseIds?: boolean;
|
||||
preserveSignatures?: boolean;
|
||||
sanitizeThoughtSignatures?: {
|
||||
|
||||
Reference in New Issue
Block a user