mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 12:14:46 +00:00
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 head9a4605ee38. - Required merge gates passed before the squash merge. Prepared head SHA:9a4605ee38Review: https://github.com/openclaw/openclaw/pull/82550#issuecomment-4466701075 Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -75,6 +75,39 @@ function ensureKimiAnthropicMaxTokens(
|
||||
payloadObj.max_tokens = current === undefined ? required : Math.max(current, required);
|
||||
}
|
||||
|
||||
function messageHasOpenAIToolCalls(message: Record<string, unknown>): boolean {
|
||||
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
||||
}
|
||||
|
||||
function ensureKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payloadObj.messages)) {
|
||||
return;
|
||||
}
|
||||
for (const message of payloadObj.messages) {
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.role !== "assistant" || !messageHasOpenAIToolCalls(record)) {
|
||||
continue;
|
||||
}
|
||||
if (!("reasoning_content" in record)) {
|
||||
record.reasoning_content = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payloadObj.messages)) {
|
||||
return;
|
||||
}
|
||||
for (const message of payloadObj.messages) {
|
||||
if (message && typeof message === "object") {
|
||||
delete (message as Record<string, unknown>).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;
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
@@ -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(
|
||||
|
||||
@@ -2552,6 +2552,7 @@ function sanitizeReasoningContentReplayFields(record: Record<string, unknown>):
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user