diff --git a/CHANGELOG.md b/CHANGELOG.md index 818daec3ac6..46c97b7cca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.:`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314. - Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code. - Codex harness: drop invalid legacy app-server `serviceTier` values such as `"priority"` before native thread and turn requests, while keeping supported Codex tiers limited to `"fast"` and `"flex"`. Fixes #64815. - Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9. diff --git a/extensions/moonshot/index.test.ts b/extensions/moonshot/index.test.ts index d8660f35ebc..6fe4adcf4a7 100644 --- a/extensions/moonshot/index.test.ts +++ b/extensions/moonshot/index.test.ts @@ -5,22 +5,22 @@ import { createCapturedThinkingConfigStream } from "../../test/helpers/plugins/s import plugin from "./index.js"; describe("moonshot provider plugin", () => { - it("owns replay policy for OpenAI-compatible Moonshot transports", async () => { + it("owns replay policy for OpenAI-compatible Moonshot transports without mangling native Kimi tool_call IDs", async () => { const provider = await registerSingleProviderPlugin(plugin); - expect( - provider.buildReplayPolicy?.({ - provider: "moonshot", - modelApi: "openai-completions", - modelId: "kimi-k2.6", - } as never), - ).toMatchObject({ - sanitizeToolCallIds: true, - toolCallIdMode: "strict", + const policy = provider.buildReplayPolicy?.({ + provider: "moonshot", + modelApi: "openai-completions", + modelId: "kimi-k2.6", + } as never); + + expect(policy).toMatchObject({ applyAssistantFirstOrderingFix: true, validateGeminiTurns: true, validateAnthropicTurns: true, }); + expect(policy).not.toHaveProperty("sanitizeToolCallIds"); + expect(policy).not.toHaveProperty("toolCallIdMode"); }); it("wires moonshot-thinking stream hooks", async () => { diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 137fbd7ebae..06c4a4d5507 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,5 +1,5 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildProviderReplayFamilyHooks } 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"; @@ -57,7 +57,13 @@ export default defineSingleProviderPluginEntry({ }, applyNativeStreamingUsageCompat: ({ providerConfig }) => applyMoonshotNativeStreamingUsageCompat(providerConfig), - ...OPENAI_COMPATIBLE_REPLAY_HOOKS, + // 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, + }), ...MOONSHOT_THINKING_STREAM_HOOKS, resolveThinkingProfile: () => ({ levels: [ diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 2f88ceddd72..64121ee12cf 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -191,6 +191,23 @@ describe("buildProviderReplayFamilyHooks", () => { validateGeminiTurns: true, }); + const nativeIdsHooks = buildProviderReplayFamilyHooks({ + family: "openai-compatible", + sanitizeToolCallIds: false, + }); + const nativeIdsPolicy = nativeIdsHooks.buildReplayPolicy?.({ + provider: "moonshot", + modelApi: "openai-completions", + modelId: "kimi-k2.6", + } as never); + expect(nativeIdsPolicy).toMatchObject({ + applyAssistantFirstOrderingFix: true, + validateGeminiTurns: true, + validateAnthropicTurns: true, + }); + expect(nativeIdsPolicy).not.toHaveProperty("sanitizeToolCallIds"); + expect(nativeIdsPolicy).not.toHaveProperty("toolCallIdMode"); + expect( PASSTHROUGH_GEMINI_REPLAY_HOOKS.buildReplayPolicy?.({ provider: "openrouter", diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index ddbdb932e12..efd957beab8 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -118,7 +118,7 @@ type ProviderReplayFamilyHooks = Pick< >; type BuildProviderReplayFamilyHooksOptions = - | { family: "openai-compatible" } + | { family: "openai-compatible"; sanitizeToolCallIds?: boolean } | { family: "anthropic-by-model" } | { family: "native-anthropic-by-model" } | { family: "google-gemini" } @@ -132,11 +132,13 @@ export function buildProviderReplayFamilyHooks( options: BuildProviderReplayFamilyHooksOptions, ): ProviderReplayFamilyHooks { switch (options.family) { - case "openai-compatible": + case "openai-compatible": { + const policyOptions = { sanitizeToolCallIds: options.sanitizeToolCallIds }; return { buildReplayPolicy: (ctx: ProviderReplayPolicyContext) => - buildOpenAICompatibleReplayPolicy(ctx.modelApi), + buildOpenAICompatibleReplayPolicy(ctx.modelApi, policyOptions), }; + } case "anthropic-by-model": return { buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) => diff --git a/src/plugins/provider-replay-helpers.test.ts b/src/plugins/provider-replay-helpers.test.ts index bc1fc1af578..90ff8f60694 100644 --- a/src/plugins/provider-replay-helpers.test.ts +++ b/src/plugins/provider-replay-helpers.test.ts @@ -22,6 +22,32 @@ describe("provider replay helpers", () => { }); }); + it("omits tool-call id sanitization when opted out for openai-completions", () => { + const policy = buildOpenAICompatibleReplayPolicy("openai-completions", { + sanitizeToolCallIds: false, + }); + expect(policy).toMatchObject({ + applyAssistantFirstOrderingFix: true, + validateGeminiTurns: true, + validateAnthropicTurns: true, + }); + expect(policy).not.toHaveProperty("sanitizeToolCallIds"); + expect(policy).not.toHaveProperty("toolCallIdMode"); + }); + + it("omits tool-call id sanitization when opted out for openai-responses", () => { + const policy = buildOpenAICompatibleReplayPolicy("openai-responses", { + sanitizeToolCallIds: false, + }); + expect(policy).toMatchObject({ + applyAssistantFirstOrderingFix: false, + validateGeminiTurns: false, + validateAnthropicTurns: false, + }); + expect(policy).not.toHaveProperty("sanitizeToolCallIds"); + expect(policy).not.toHaveProperty("toolCallIdMode"); + }); + it("builds strict anthropic replay policy", () => { expect(buildStrictAnthropicReplayPolicy({ dropThinkingBlocks: true })).toMatchObject({ sanitizeMode: "full", diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index 134a19318a4..e880757d0f4 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -11,6 +11,7 @@ import type { export function buildOpenAICompatibleReplayPolicy( modelApi: string | null | undefined, + options: { sanitizeToolCallIds?: boolean } = {}, ): ProviderReplayPolicy | undefined { if ( modelApi !== "openai-completions" && @@ -21,9 +22,12 @@ export function buildOpenAICompatibleReplayPolicy( return undefined; } + const sanitizeToolCallIds = options.sanitizeToolCallIds ?? true; + return { - sanitizeToolCallIds: true, - toolCallIdMode: "strict", + ...(sanitizeToolCallIds + ? { sanitizeToolCallIds: true, toolCallIdMode: "strict" as const } + : {}), ...(modelApi === "openai-completions" ? { applyAssistantFirstOrderingFix: true,