diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf27bdad67..6030ce3a01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai - Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout. - ACPX/Codex: reap plugin-local Codex ACP adapter orphans on startup after wrapper crashes while keeping direct adapter commands out of launch-lease injection. Fixes #82364. (#82459) Thanks @joshavant. - Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant. +- Providers/Kimi: preserve Kimi Coding `reasoning_content` replay and backfill assistant tool-call placeholders when thinking is enabled, so `kimi-for-coding` follow-up tool turns no longer fail after prior tool use. Fixes #82161. Thanks @amknight. - Providers/search tools: reject malformed successful xAI, Gemini, and Kimi web/code search responses with provider-owned errors instead of silent `No response` payloads or ungrounded fallback state. - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. - Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart. diff --git a/extensions/kimi-coding/stream.test.ts b/extensions/kimi-coding/stream.test.ts index 9b58c8c76b1..ff9b2e770ff 100644 --- a/extensions/kimi-coding/stream.test.ts +++ b/extensions/kimi-coding/stream.test.ts @@ -307,6 +307,126 @@ describe("kimi tool-call markup wrapper", () => { }); }); + it("backfills Kimi OpenAI-compatible tool-call reasoning_content when thinking is enabled", () => { + const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({ + messages: [ + { role: "user", content: "run pwd" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + }, + ], + }, + { + role: "assistant", + content: "kept", + reasoning_content: "native reasoning", + tool_calls: [ + { + id: "call_2", + type: "function", + function: { name: "read", arguments: "{}" }, + }, + ], + }, + ], + }); + + const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled"); + void wrapped( + { + api: "openai-completions", + provider: "kimi", + id: "kimi-for-coding", + } as Model<"openai-completions">, + { messages: [] } as Context, + {}, + ); + + expect(getCapturedPayload()).toEqual({ + messages: [ + { role: "user", content: "run pwd" }, + { + role: "assistant", + content: null, + reasoning_content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + }, + ], + }, + { + role: "assistant", + content: "kept", + reasoning_content: "native reasoning", + tool_calls: [ + { + id: "call_2", + type: "function", + function: { name: "read", arguments: "{}" }, + }, + ], + }, + ], + thinking: { type: "enabled" }, + }); + }); + + it("strips Kimi OpenAI-compatible replay reasoning_content when thinking is disabled", () => { + const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({ + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "old reasoning", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + }, + ], + }, + ], + }); + + const wrapped = createKimiThinkingWrapper(baseStreamFn, "disabled"); + void wrapped( + { + api: "openai-completions", + provider: "kimi", + id: "kimi-for-coding", + } as Model<"openai-completions">, + { messages: [] } as Context, + {}, + ); + + expect(getCapturedPayload()).toEqual({ + messages: [ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + }, + ], + }, + ], + thinking: { type: "disabled" }, + }); + }); + it("enables Kimi Anthropic thinking with a high budget and enough output room", () => { const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream(); diff --git a/extensions/kimi-coding/stream.ts b/extensions/kimi-coding/stream.ts index ac23cf0d0db..ed11e535a28 100644 --- a/extensions/kimi-coding/stream.ts +++ b/extensions/kimi-coding/stream.ts @@ -75,6 +75,39 @@ function ensureKimiAnthropicMaxTokens( payloadObj.max_tokens = current === undefined ? required : Math.max(current, required); } +function messageHasOpenAIToolCalls(message: Record): boolean { + return Array.isArray(message.tool_calls) && message.tool_calls.length > 0; +} + +function ensureKimiOpenAIReasoningContent(payloadObj: Record): void { + if (!Array.isArray(payloadObj.messages)) { + return; + } + for (const message of payloadObj.messages) { + if (!message || typeof message !== "object") { + continue; + } + const record = message as Record; + if (record.role !== "assistant" || !messageHasOpenAIToolCalls(record)) { + continue; + } + if (!("reasoning_content" in record)) { + record.reasoning_content = ""; + } + } +} + +function stripKimiOpenAIReasoningContent(payloadObj: Record): void { + if (!Array.isArray(payloadObj.messages)) { + return; + } + for (const message of payloadObj.messages) { + if (message && typeof message === "object") { + delete (message as Record).reasoning_content; + } + } +} + function normalizeKimiThinkingType(value: unknown): KimiThinkingType | undefined { if (typeof value === "boolean") { return value ? "enabled" : "disabled"; @@ -331,6 +364,10 @@ export function createKimiThinkingWrapper( model.api === "anthropic-messages" ? { ...normalized } : { type: normalized.type }; if (model.api === "anthropic-messages") { ensureKimiAnthropicMaxTokens(payloadObj, normalized); + } else if (normalized.type === "enabled") { + ensureKimiOpenAIReasoningContent(payloadObj); + } else { + stripKimiOpenAIReasoningContent(payloadObj); } delete payloadObj.reasoning; delete payloadObj.reasoning_effort; diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 0a555b061d7..0f313ca7920 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -5725,6 +5725,14 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () => maxTokens: 32_000, } satisfies Model<"openai-completions">; + const kimiCodingProxyModel = { + ...customKimiProxyModel, + id: "kimi-for-coding", + name: "Kimi for Coding", + provider: "kimi", + baseUrl: "https://api.kimi.com/coding/v1", + } satisfies Model<"openai-completions">; + function getAssistantMessage(params: { messages: unknown }) { expect(Array.isArray(params.messages)).toBe(true); const list = params.messages as Array>; @@ -5916,6 +5924,17 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () => expect(assistant).not.toHaveProperty("reasoning_text"); }); + it("preserves reasoning_content replay for Kimi Coding OpenAI-compatible routes", () => { + const assistant = getAssistantMessage( + buildReplayParams(kimiCodingProxyModel, "reasoning_content"), + ); + + expect(assistant.reasoning_content).toBe("Need to answer politely."); + expect(assistant).not.toHaveProperty("reasoning_details"); + expect(assistant).not.toHaveProperty("reasoning"); + expect(assistant).not.toHaveProperty("reasoning_text"); + }); + it("preserves reasoning_content replay for suffixed reasoning model ids", () => { const assistant = getAssistantMessage( buildReplayParams( @@ -5930,6 +5949,20 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () => expect(assistant.reasoning_content).toBe("Need to answer politely."); }); + it("preserves reasoning_content replay for prefixed reasoning model ids", () => { + const assistant = getAssistantMessage( + buildReplayParams( + { + ...customKimiProxyModel, + id: "hf:moonshotai/kimi-k2-thinking", + }, + "reasoning_content", + ), + ); + + expect(assistant.reasoning_content).toBe("Need to answer politely."); + }); + it("preserves OpenRouter array reasoning_details from tool-call signatures", () => { const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" }; const params = buildOpenAICompletionsParams( diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 3cc8061794a..2a1be12590b 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -2552,6 +2552,7 @@ function sanitizeReasoningContentReplayFields(record: Record): const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ "deepseek-v4-flash", "deepseek-v4-pro", + "kimi-for-coding", "kimi-k2.5", "kimi-k2.6", "kimi-k2-thinking", @@ -2563,16 +2564,22 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ "mimo-v2.6-pro", ]); -function normalizeReasoningContentReplayModelId(modelId: unknown): string | undefined { +function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] { if (typeof modelId !== "string") { - return undefined; + return []; } - const normalized = modelId.trim().toLowerCase().split(":", 1)[0]; + const normalized = modelId.trim().toLowerCase(); if (!normalized) { - return undefined; + return []; } const parts = normalized.split("/").filter(Boolean); - return parts[parts.length - 1] ?? normalized; + const finalPart = parts[parts.length - 1] ?? normalized; + const candidates = [finalPart]; + const colonParts = finalPart.split(":").filter(Boolean); + if (colonParts.length > 1) { + candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? ""); + } + return [...new Set(candidates.filter(Boolean))]; } function shouldPreserveReasoningContentReplay( @@ -2586,9 +2593,8 @@ function shouldPreserveReasoningContentReplay( ) { return true; } - const normalizedModelId = normalizeReasoningContentReplayModelId(model.id); - return ( - normalizedModelId !== undefined && REASONING_CONTENT_REPLAY_MODEL_IDS.has(normalizedModelId) + return getReasoningContentReplayModelIdCandidates(model.id).some((modelId) => + REASONING_CONTENT_REPLAY_MODEL_IDS.has(modelId), ); } diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 9a84ee1f6b7..402f6bc7aaa 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -362,7 +362,14 @@ describe("resolveTranscriptPolicy", () => { expect(responsesPolicy.dropReasoningFromHistory).toBe(false); }); - it.each(["moonshotai/kimi-k2.6", "kimi-k2-thinking", "xiaomi/mimo-v2.6-pro"])( + it.each([ + "kimi-for-coding", + "moonshotai/kimi-k2.6", + "kimi-k2-thinking", + "hf:moonshotai/kimi-k2-thinking", + "xiaomi/mimo-v2.6-pro", + "xiaomi/mimo-v2.6-pro:cloud", + ])( "preserves historical reasoning for %s replay-required OpenAI-compatible models", (modelId) => { const policy = resolveTranscriptPolicy({ diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 7b0ab0374a0..a3b093d6d76 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -158,6 +158,7 @@ function buildUnownedProviderTransportReplayFallback(params: { } const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ + "kimi-for-coding", "kimi-k2.5", "kimi-k2.6", "kimi-k2-thinking", @@ -170,13 +171,18 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ ]); function requiresReasoningContentReplay(modelId: string | null | undefined): boolean { - const normalized = normalizeLowercaseStringOrEmpty(modelId).split(":", 1)[0]; + const normalized = normalizeLowercaseStringOrEmpty(modelId); if (!normalized) { return false; } const parts = normalized.split("/").filter(Boolean); const finalPart = parts[parts.length - 1] ?? normalized; - return REASONING_CONTENT_REPLAY_MODEL_IDS.has(finalPart); + const candidates = [finalPart]; + const colonParts = finalPart.split(":").filter(Boolean); + if (colonParts.length > 1) { + candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? ""); + } + return candidates.some((candidate) => REASONING_CONTENT_REPLAY_MODEL_IDS.has(candidate)); } function mergeTranscriptPolicy(