diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index 0cf000359cfd..c17d94219cbe 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -373,7 +373,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`: - Moonshot Kimi serves tool_call ids shaped like `functions.:`. OpenClaw preserves them unchanged so multi-turn tool calls keep working. + Moonshot Kimi serves native tool_call ids shaped like `functions.:`. 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`: diff --git a/extensions/moonshot/index.test.ts b/extensions/moonshot/index.test.ts index fcfbccc4b962..5bacf6d6d41a 100644 --- a/extensions/moonshot/index.test.ts +++ b/extensions/moonshot/index.test.ts @@ -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 () => { diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 6339769c866b..582043b78458 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -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.:`. - // 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 diff --git a/src/agents/embedded-agent-helpers/images.ts b/src/agents/embedded-agent-helpers/images.ts index 213e5fafb362..ae113df025ad 100644 --- a/src/agents/embedded-agent-helpers/images.ts +++ b/src/agents/embedded-agent-helpers/images.ts @@ -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[] = []; diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index 095a3f78e9a5..822552604bdc 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -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, }) diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts index cf7f7d4d4af1..7496ee28f202 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts @@ -1159,11 +1159,13 @@ export function sanitizeReplayToolCallIdsForStream(params: { mode: ToolCallIdMode; allowedToolNames?: Set; 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, }); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 34f2e8f02daa..72b5f112cde8 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -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, diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index f36c3bb385b9..fb4178d40882 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -299,6 +299,7 @@ export type AgentRuntimeTranscriptPolicy = { sanitizeMode: "full" | "images-only"; sanitizeToolCallIds: boolean; toolCallIdMode?: AgentRuntimeToolCallIdMode; + duplicateToolCallIdStyle?: "openai"; preserveNativeAnthropicToolUseIds: boolean; repairToolUseResultPairing: boolean; preserveSignatures: boolean; diff --git a/src/agents/tool-call-id.test.ts b/src/agents/tool-call-id.test.ts index 2904f2fce160..477f89fa2a7a 100644 --- a/src/agents/tool-call-id.test.ts +++ b/src/agents/tool-call-id.test.ts @@ -536,6 +536,36 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => { expect((out[3] as Extract).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).content; + const secondContent = (out[2] as Extract).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).toolCallId).toBe(firstId); + expect((out[3] as Extract).toolCallId).toBe(secondId); + expect(sanitizeToolCallIdsForCloudCodeAssist(out, "strict", options)).toBe(out); + }); + it("does not preserve malformed Kimi-like ids", () => { for (const bad of [ "functions.read", diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index 0ab5d738c5f7..7362fa13606a 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -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; }, ): { @@ -298,6 +300,7 @@ function createOccurrenceAwareResolver( const orphanToolResultOccurrences = new Map(); const pendingByRawId = new Map(); 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; }, diff --git a/src/agents/transcript-policy.policy.test.ts b/src/agents/transcript-policy.policy.test.ts index 14427f5c763d..8fa9ad826ca3 100644 --- a/src/agents/transcript-policy.policy.test.ts +++ b/src/agents/transcript-policy.policy.test.ts @@ -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"); + }); }); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index f9c86c5438c6..1d78ea327047 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -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 } : {}), diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index 53d76b6a89dc..4bfe2f9b0fd3 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -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 { diff --git a/src/plugins/provider-replay-helpers.test.ts b/src/plugins/provider-replay-helpers.test.ts index 8547814d1912..64ce305add10 100644 --- a/src/plugins/provider-replay-helpers.test.ts +++ b/src/plugins/provider-replay-helpers.test.ts @@ -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", { diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index 24c3a1fc6a34..7d8de195692a 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -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" diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 8e3a71659836..715aabb4ebc1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -790,6 +790,7 @@ export type ProviderReplayPolicy = { sanitizeMode?: ProviderReplaySanitizeMode; sanitizeToolCallIds?: boolean; toolCallIdMode?: ProviderReplayToolCallIdMode; + duplicateToolCallIdStyle?: "openai"; preserveNativeAnthropicToolUseIds?: boolean; preserveSignatures?: boolean; sanitizeThoughtSignatures?: {