Skip to content

Commit f8b7008

Browse files
authored
Fix Kimi Coding tool-call replay (#82550)
Summary: - The PR preserves Kimi Coding reasoning_content replay for OpenAI-compatible tool-call follow-up turns, extends replay model-id matching, adds Kimi wrapper/tests, and updates the changelog. - Reproducibility: yes. at source level: current main drops or fails to synthesize reasoning_content for kimi- ... es a concrete Kimi 400 after tool-call history. I did not run a live Kimi request in this read-only review. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head 9a4605e. - Required merge gates passed before the squash merge. Prepared head SHA: 9a4605e Review: #82550 (comment) Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
1 parent 9558b2c commit f8b7008

7 files changed

Lines changed: 221 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai
8282
- Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout.
8383
- 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.
8484
- Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant.
85+
- 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.
8586
- 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.
8687
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
8788
- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart.

extensions/kimi-coding/stream.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,126 @@ describe("kimi tool-call markup wrapper", () => {
307307
});
308308
});
309309

310+
it("backfills Kimi OpenAI-compatible tool-call reasoning_content when thinking is enabled", () => {
311+
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
312+
messages: [
313+
{ role: "user", content: "run pwd" },
314+
{
315+
role: "assistant",
316+
content: null,
317+
tool_calls: [
318+
{
319+
id: "call_1",
320+
type: "function",
321+
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
322+
},
323+
],
324+
},
325+
{
326+
role: "assistant",
327+
content: "kept",
328+
reasoning_content: "native reasoning",
329+
tool_calls: [
330+
{
331+
id: "call_2",
332+
type: "function",
333+
function: { name: "read", arguments: "{}" },
334+
},
335+
],
336+
},
337+
],
338+
});
339+
340+
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
341+
void wrapped(
342+
{
343+
api: "openai-completions",
344+
provider: "kimi",
345+
id: "kimi-for-coding",
346+
} as Model<"openai-completions">,
347+
{ messages: [] } as Context,
348+
{},
349+
);
350+
351+
expect(getCapturedPayload()).toEqual({
352+
messages: [
353+
{ role: "user", content: "run pwd" },
354+
{
355+
role: "assistant",
356+
content: null,
357+
reasoning_content: "",
358+
tool_calls: [
359+
{
360+
id: "call_1",
361+
type: "function",
362+
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
363+
},
364+
],
365+
},
366+
{
367+
role: "assistant",
368+
content: "kept",
369+
reasoning_content: "native reasoning",
370+
tool_calls: [
371+
{
372+
id: "call_2",
373+
type: "function",
374+
function: { name: "read", arguments: "{}" },
375+
},
376+
],
377+
},
378+
],
379+
thinking: { type: "enabled" },
380+
});
381+
});
382+
383+
it("strips Kimi OpenAI-compatible replay reasoning_content when thinking is disabled", () => {
384+
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
385+
messages: [
386+
{
387+
role: "assistant",
388+
content: null,
389+
reasoning_content: "old reasoning",
390+
tool_calls: [
391+
{
392+
id: "call_1",
393+
type: "function",
394+
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
395+
},
396+
],
397+
},
398+
],
399+
});
400+
401+
const wrapped = createKimiThinkingWrapper(baseStreamFn, "disabled");
402+
void wrapped(
403+
{
404+
api: "openai-completions",
405+
provider: "kimi",
406+
id: "kimi-for-coding",
407+
} as Model<"openai-completions">,
408+
{ messages: [] } as Context,
409+
{},
410+
);
411+
412+
expect(getCapturedPayload()).toEqual({
413+
messages: [
414+
{
415+
role: "assistant",
416+
content: null,
417+
tool_calls: [
418+
{
419+
id: "call_1",
420+
type: "function",
421+
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
422+
},
423+
],
424+
},
425+
],
426+
thinking: { type: "disabled" },
427+
});
428+
});
429+
310430
it("enables Kimi Anthropic thinking with a high budget and enough output room", () => {
311431
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
312432

extensions/kimi-coding/stream.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,39 @@ function ensureKimiAnthropicMaxTokens(
7575
payloadObj.max_tokens = current === undefined ? required : Math.max(current, required);
7676
}
7777

78+
function messageHasOpenAIToolCalls(message: Record<string, unknown>): boolean {
79+
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
80+
}
81+
82+
function ensureKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
83+
if (!Array.isArray(payloadObj.messages)) {
84+
return;
85+
}
86+
for (const message of payloadObj.messages) {
87+
if (!message || typeof message !== "object") {
88+
continue;
89+
}
90+
const record = message as Record<string, unknown>;
91+
if (record.role !== "assistant" || !messageHasOpenAIToolCalls(record)) {
92+
continue;
93+
}
94+
if (!("reasoning_content" in record)) {
95+
record.reasoning_content = "";
96+
}
97+
}
98+
}
99+
100+
function stripKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
101+
if (!Array.isArray(payloadObj.messages)) {
102+
return;
103+
}
104+
for (const message of payloadObj.messages) {
105+
if (message && typeof message === "object") {
106+
delete (message as Record<string, unknown>).reasoning_content;
107+
}
108+
}
109+
}
110+
78111
function normalizeKimiThinkingType(value: unknown): KimiThinkingType | undefined {
79112
if (typeof value === "boolean") {
80113
return value ? "enabled" : "disabled";
@@ -331,6 +364,10 @@ export function createKimiThinkingWrapper(
331364
model.api === "anthropic-messages" ? { ...normalized } : { type: normalized.type };
332365
if (model.api === "anthropic-messages") {
333366
ensureKimiAnthropicMaxTokens(payloadObj, normalized);
367+
} else if (normalized.type === "enabled") {
368+
ensureKimiOpenAIReasoningContent(payloadObj);
369+
} else {
370+
stripKimiOpenAIReasoningContent(payloadObj);
334371
}
335372
delete payloadObj.reasoning;
336373
delete payloadObj.reasoning_effort;

src/agents/openai-transport-stream.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5725,6 +5725,14 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
57255725
maxTokens: 32_000,
57265726
} satisfies Model<"openai-completions">;
57275727

5728+
const kimiCodingProxyModel = {
5729+
...customKimiProxyModel,
5730+
id: "kimi-for-coding",
5731+
name: "Kimi for Coding",
5732+
provider: "kimi",
5733+
baseUrl: "https://api.kimi.com/coding/v1",
5734+
} satisfies Model<"openai-completions">;
5735+
57285736
function getAssistantMessage(params: { messages: unknown }) {
57295737
expect(Array.isArray(params.messages)).toBe(true);
57305738
const list = params.messages as Array<Record<string, unknown>>;
@@ -5916,6 +5924,17 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
59165924
expect(assistant).not.toHaveProperty("reasoning_text");
59175925
});
59185926

5927+
it("preserves reasoning_content replay for Kimi Coding OpenAI-compatible routes", () => {
5928+
const assistant = getAssistantMessage(
5929+
buildReplayParams(kimiCodingProxyModel, "reasoning_content"),
5930+
);
5931+
5932+
expect(assistant.reasoning_content).toBe("Need to answer politely.");
5933+
expect(assistant).not.toHaveProperty("reasoning_details");
5934+
expect(assistant).not.toHaveProperty("reasoning");
5935+
expect(assistant).not.toHaveProperty("reasoning_text");
5936+
});
5937+
59195938
it("preserves reasoning_content replay for suffixed reasoning model ids", () => {
59205939
const assistant = getAssistantMessage(
59215940
buildReplayParams(
@@ -5930,6 +5949,20 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
59305949
expect(assistant.reasoning_content).toBe("Need to answer politely.");
59315950
});
59325951

5952+
it("preserves reasoning_content replay for prefixed reasoning model ids", () => {
5953+
const assistant = getAssistantMessage(
5954+
buildReplayParams(
5955+
{
5956+
...customKimiProxyModel,
5957+
id: "hf:moonshotai/kimi-k2-thinking",
5958+
},
5959+
"reasoning_content",
5960+
),
5961+
);
5962+
5963+
expect(assistant.reasoning_content).toBe("Need to answer politely.");
5964+
});
5965+
59335966
it("preserves OpenRouter array reasoning_details from tool-call signatures", () => {
59345967
const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" };
59355968
const params = buildOpenAICompletionsParams(

src/agents/openai-transport-stream.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,6 +2552,7 @@ function sanitizeReasoningContentReplayFields(record: Record<string, unknown>):
25522552
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
25532553
"deepseek-v4-flash",
25542554
"deepseek-v4-pro",
2555+
"kimi-for-coding",
25552556
"kimi-k2.5",
25562557
"kimi-k2.6",
25572558
"kimi-k2-thinking",
@@ -2563,16 +2564,22 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
25632564
"mimo-v2.6-pro",
25642565
]);
25652566

2566-
function normalizeReasoningContentReplayModelId(modelId: unknown): string | undefined {
2567+
function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] {
25672568
if (typeof modelId !== "string") {
2568-
return undefined;
2569+
return [];
25692570
}
2570-
const normalized = modelId.trim().toLowerCase().split(":", 1)[0];
2571+
const normalized = modelId.trim().toLowerCase();
25712572
if (!normalized) {
2572-
return undefined;
2573+
return [];
25732574
}
25742575
const parts = normalized.split("/").filter(Boolean);
2575-
return parts[parts.length - 1] ?? normalized;
2576+
const finalPart = parts[parts.length - 1] ?? normalized;
2577+
const candidates = [finalPart];
2578+
const colonParts = finalPart.split(":").filter(Boolean);
2579+
if (colonParts.length > 1) {
2580+
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
2581+
}
2582+
return [...new Set(candidates.filter(Boolean))];
25762583
}
25772584

25782585
function shouldPreserveReasoningContentReplay(
@@ -2586,9 +2593,8 @@ function shouldPreserveReasoningContentReplay(
25862593
) {
25872594
return true;
25882595
}
2589-
const normalizedModelId = normalizeReasoningContentReplayModelId(model.id);
2590-
return (
2591-
normalizedModelId !== undefined && REASONING_CONTENT_REPLAY_MODEL_IDS.has(normalizedModelId)
2596+
return getReasoningContentReplayModelIdCandidates(model.id).some((modelId) =>
2597+
REASONING_CONTENT_REPLAY_MODEL_IDS.has(modelId),
25922598
);
25932599
}
25942600

src/agents/transcript-policy.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,14 @@ describe("resolveTranscriptPolicy", () => {
362362
expect(responsesPolicy.dropReasoningFromHistory).toBe(false);
363363
});
364364

365-
it.each(["moonshotai/kimi-k2.6", "kimi-k2-thinking", "xiaomi/mimo-v2.6-pro"])(
365+
it.each([
366+
"kimi-for-coding",
367+
"moonshotai/kimi-k2.6",
368+
"kimi-k2-thinking",
369+
"hf:moonshotai/kimi-k2-thinking",
370+
"xiaomi/mimo-v2.6-pro",
371+
"xiaomi/mimo-v2.6-pro:cloud",
372+
])(
366373
"preserves historical reasoning for %s replay-required OpenAI-compatible models",
367374
(modelId) => {
368375
const policy = resolveTranscriptPolicy({

src/agents/transcript-policy.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ function buildUnownedProviderTransportReplayFallback(params: {
158158
}
159159

160160
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
161+
"kimi-for-coding",
161162
"kimi-k2.5",
162163
"kimi-k2.6",
163164
"kimi-k2-thinking",
@@ -170,13 +171,18 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
170171
]);
171172

172173
function requiresReasoningContentReplay(modelId: string | null | undefined): boolean {
173-
const normalized = normalizeLowercaseStringOrEmpty(modelId).split(":", 1)[0];
174+
const normalized = normalizeLowercaseStringOrEmpty(modelId);
174175
if (!normalized) {
175176
return false;
176177
}
177178
const parts = normalized.split("/").filter(Boolean);
178179
const finalPart = parts[parts.length - 1] ?? normalized;
179-
return REASONING_CONTENT_REPLAY_MODEL_IDS.has(finalPart);
180+
const candidates = [finalPart];
181+
const colonParts = finalPart.split(":").filter(Boolean);
182+
if (colonParts.length > 1) {
183+
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
184+
}
185+
return candidates.some((candidate) => REASONING_CONTENT_REPLAY_MODEL_IDS.has(candidate));
180186
}
181187

182188
function mergeTranscriptPolicy(

0 commit comments

Comments
 (0)