Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kimi-native-tool-call-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix sessions getting stuck with self-hosted Kimi models behind an OpenAI-compatible endpoint, where a turn ended with reasoning and no tool call.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
* tool-result `extract_text` fallback and tool-declaration-only skip are
* handed over to the trait wholesale: every history message is
* base-converted, post-processed by the hook, and dropped on `null`.
*
* Its tool-call-id policy preserves Kimi's canonical native id shape; see
* `KIMI_NATIVE_TOOL_CALL_ID` for the rationale and trade-off.
*/

import OpenAI from 'openai';
Expand Down Expand Up @@ -78,13 +81,16 @@ import {
requireProviderApiKey,
resolveAuthBackedClient,
} from '../request-auth';
import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id';
import {
normalizeToolCallIdsForProvider,
sanitizeToolCallIdPreservingNative,
} from '../tool-call-id';


const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024;

export const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
normalize: (id) => sanitizeToolCallId(id, 64),
normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64),
maxLength: 64,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
* classification (already-converted errors crossing an outer catch pass
* through without re-consulting). The developer-role model detection lives
* here.
*
* Its tool-call-id policy preserves Kimi's canonical native id shape; see
* `KIMI_NATIVE_TOOL_CALL_ID` for the rationale and trade-off.
*/

import OpenAI from 'openai';
Expand Down Expand Up @@ -60,7 +63,10 @@ import {
requireProviderApiKey,
resolveAuthBackedClient,
} from '../request-auth';
import { normalizeToolCallIdsForProvider, sanitizeOpenAIResponsesCallId } from '../tool-call-id';
import {
normalizeToolCallIdsForProvider,
sanitizeOpenAIResponsesCallIdPreservingNative,
} from '../tool-call-id';

function normalizeResponsesFinishReason(
status: string | null | undefined,
Expand Down Expand Up @@ -92,7 +98,7 @@ function normalizeResponsesFinishReason(

type RawObject = Record<string, unknown>;
const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64),
normalize: (id) => sanitizeOpenAIResponsesCallIdPreservingNative(id, 64),
maxLength: 64,
};

Expand Down
37 changes: 37 additions & 0 deletions packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
* The shared `ToolCallIdPolicy` implementation: id sanitization plus
* history-wide id normalization that rewrites every `toolCalls[].id` /
* `toolCallId` pair consistently and keeps rewritten ids unique.
*
* It also owns the Kimi native-id carve-out. Kimi-K2 is trained to see its
* canonical id shape `functions.<name>:<idx>` echoed back in history;
* sanitizing it makes the model reply with reasoning and no tool call, so the
* OpenAI and Kimi policies preserve that shape verbatim while
* charset-restricted providers (Anthropic) keep sanitizing. The guard is keyed
* on the id shape, not the provider, because self-hosted Kimi is configured as
* a plain `openai` provider and does not self-identify here — the trade-off is
* that a Kimi-authored id replayed onto a stricter OpenAI-compatible backend
* is passed through rather than sanitized. Length handling follows each API:
* the chat `tool_call.id` is uncapped, so a canonical id survives at any
* length and the cap bounds only the sanitized fallback, while the Responses
* `call_id` caps at 64, so an over-long canonical id normalizes instead.
*/

import type { Message, ToolCall } from '#/kosong/contract/message';
Expand All @@ -17,11 +30,35 @@ export function sanitizeToolCallId(id: string, maxLength?: number): string {
return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength);
}

const KIMI_NATIVE_TOOL_CALL_ID = /^functions\.[A-Za-z0-9_-]+:\d+$/;

export function sanitizeToolCallIdPreservingNative(
id: string,
fallbackMaxLength?: number,
): string {
return KIMI_NATIVE_TOOL_CALL_ID.test(id) ? id : sanitizeToolCallId(id, fallbackMaxLength);
}

function isPreservableResponsesCallId(id: string, maxLength?: number): boolean {
return KIMI_NATIVE_TOOL_CALL_ID.test(id) && (maxLength === undefined || id.length <= maxLength);
}

export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string {
const [callId] = id.split('|', 1);
return sanitizeToolCallId(callId ?? id, maxLength);
}

export function sanitizeOpenAIResponsesCallIdPreservingNative(
id: string,
maxLength?: number,
): string {
const [callId] = id.split('|', 1);
const base = callId ?? id;
return isPreservableResponsesCallId(base, maxLength)
? base
: sanitizeOpenAIResponsesCallId(id, maxLength);
}

export function normalizeToolCallIdsForProvider(
messages: Message[],
policy: ToolCallIdPolicy,
Expand Down
111 changes: 110 additions & 1 deletion packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,14 +657,15 @@ async function captureGoogleBody(
async function captureResponsesBody(
provider: ChatProvider,
options?: GenerateOptions,
history: Message[] = PROBE_HISTORY,
): Promise<Record<string, unknown>> {
let captured: Record<string, unknown> | undefined;
const client = sdkClient(provider) as { responses: { create: unknown } };
client.responses.create = vi.fn().mockImplementation((params: unknown) => {
captured = params as Record<string, unknown>;
return Promise.resolve(responsesEventStream());
});
await drain(await provider.generate('', [], PROBE_HISTORY, options));
await drain(await provider.generate('', [], history, options));
if (captured === undefined) throw new Error('expected responses.create to be called');
return captured;
}
Expand Down Expand Up @@ -695,6 +696,114 @@ describe('per-turn intent wire encoding (behavior probes)', () => {
expect(body).not.toHaveProperty('reasoning_effort');
});

it('preserves a canonical Kimi-native tool call id end-to-end through the composed Kimi provider', async () => {
const provider = registry.createChatProvider({
protocol: 'openai',
providerType: 'kimi',
modelName: 'kimi-k2',
apiKey: 'sk-probe',
});

const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'functions.Bash:0', name: 'Bash', arguments: '{"command":"pwd"}' },
],
},
{
role: 'tool',
content: [{ type: 'text', text: '/tmp' }],
toolCallId: 'functions.Bash:0',
toolCalls: [],
},
];

const body = await captureOpenAIBody(provider, undefined, history);
const messages = body['messages'] as Array<Record<string, unknown>>;
const assistant = messages.find((m) => m['role'] === 'assistant');
const toolResult = messages.find((m) => m['role'] === 'tool');
if (assistant === undefined || toolResult === undefined) {
throw new Error('expected assistant and tool messages on the wire');
}

const [toolCall] = assistant['tool_calls'] as Array<Record<string, unknown>>;
expect(toolCall?.['id']).toBe('functions.Bash:0');
expect(toolResult['tool_call_id']).toBe('functions.Bash:0');
});

it('sanitizes a non-canonical historical tool call id through the composed Kimi provider', async () => {
const provider = registry.createChatProvider({
protocol: 'openai',
providerType: 'kimi',
modelName: 'kimi-k2',
apiKey: 'sk-probe',
});

const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'Bash:7', name: 'Bash', arguments: '{"command":"pwd"}' },
],
},
{
role: 'tool',
content: [{ type: 'text', text: '/tmp' }],
toolCallId: 'Bash:7',
toolCalls: [],
},
];

const body = await captureOpenAIBody(provider, undefined, history);
const messages = body['messages'] as Array<Record<string, unknown>>;
const assistant = messages.find((m) => m['role'] === 'assistant');
const toolResult = messages.find((m) => m['role'] === 'tool');
if (assistant === undefined || toolResult === undefined) {
throw new Error('expected assistant and tool messages on the wire');
}

const [toolCall] = assistant['tool_calls'] as Array<Record<string, unknown>>;
expect(toolCall?.['id']).toBe('Bash_7');
expect(toolResult['tool_call_id']).toBe('Bash_7');
});

it('preserves a canonical Kimi-native tool call id through OpenAIResponsesChatProvider.generate()', async () => {
const provider = new OpenAIResponsesChatProvider({ model: 'kimi-k2', apiKey: 'sk-probe' });

const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'functions.Bash:0', name: 'Bash', arguments: '{"command":"pwd"}' },
],
},
{
role: 'tool',
content: [{ type: 'text', text: '/tmp' }],
toolCallId: 'functions.Bash:0',
toolCalls: [],
},
];

const body = await captureResponsesBody(provider, undefined, history);
const input = body['input'] as Array<Record<string, unknown>>;
const call = input.find((i) => i['type'] === 'function_call');
const output = input.find((i) => i['type'] === 'function_call_output');
if (call === undefined || output === undefined) {
throw new Error('expected function_call and function_call_output items on the wire');
}

expect(call['call_id']).toBe('functions.Bash:0');
expect(output['call_id']).toBe('functions.Bash:0');
});

it('encodes cacheKey on plain OpenAI as the native prompt_cache_key', async () => {
const provider = registry.createChatProvider({
protocol: 'openai',
Expand Down
4 changes: 2 additions & 2 deletions packages/kosong/src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from './request-auth';
import {
normalizeToolCallIdsForProvider,
sanitizeToolCallId,
sanitizeToolCallIdPreservingNative,
type ToolCallIdPolicy,
} from './tool-call-id';
export interface KimiOptions {
Expand Down Expand Up @@ -86,7 +86,7 @@ export interface ExtraBody {
[key: string]: unknown;
}
const KIMI_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
normalize: (id) => sanitizeToolCallId(id, 64),
normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64),
maxLength: 64,
};
interface OpenAIMessage {
Expand Down
6 changes: 4 additions & 2 deletions packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from './request-auth';
import {
normalizeToolCallIdsForProvider,
sanitizeToolCallId,
sanitizeToolCallIdPreservingNative,
type ToolCallIdPolicy,
} from './tool-call-id';

Expand All @@ -54,8 +54,10 @@ import {
* (the documented range is `[1, 131072]`).
*/
const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024;
// Preserves a canonical Kimi-native id, because self-hosted Kimi is commonly served through an
// OpenAI-compatible endpoint configured as an `openai` provider. See `KIMI_NATIVE_TOOL_CALL_ID`.
const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
normalize: (id) => sanitizeToolCallId(id, 64),
normalize: (id) => sanitizeToolCallIdPreservingNative(id, 64),
maxLength: 64,
};

Expand Down
6 changes: 4 additions & 2 deletions packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
} from './request-auth';
import {
normalizeToolCallIdsForProvider,
sanitizeOpenAIResponsesCallId,
sanitizeOpenAIResponsesCallIdPreservingNative,
type ToolCallIdPolicy,
} from './tool-call-id';

Expand Down Expand Up @@ -78,8 +78,10 @@ function normalizeResponsesFinishReason(
}

type RawObject = Record<string, unknown>;
// Preserves a canonical Kimi-native id for the same reason as the chat policy; here the cap is a
// real wire limit, so an over-long canonical id still normalizes. See `KIMI_NATIVE_TOOL_CALL_ID`.
const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64),
normalize: (id) => sanitizeOpenAIResponsesCallIdPreservingNative(id, 64),
maxLength: 64,
};

Expand Down
58 changes: 58 additions & 0 deletions packages/kosong/src/providers/tool-call-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,69 @@ export function sanitizeToolCallId(id: string, maxLength?: number): string {
return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength);
}

// Kimi-K2 tool-call ids use the canonical native shape `functions.<name>:<idx>` — the exact format
// the model is trained to see echoed back in conversation history. Sanitizing it (`.` and `:` become
// `_`) makes the model emit reasoning with no tool call and stop the turn (`APIEmptyResponseError` /
// `finishReason=stop`). Such an id is also a valid free-form id for OpenAI-compatible providers —
// the transport that serves self-hosted Kimi (litellm / vLLM / SGLang / Azure) — so those policies
// opt into preserving it verbatim, while charset-restricted providers (Anthropic,
// `^[a-zA-Z0-9_-]+$`) keep sanitizing.
//
// The guard is keyed on the id shape rather than on the provider, because self-hosted Kimi is
// configured as a plain `openai` provider and does not self-identify at the policy layer. Trade-off:
// a Kimi-authored id replayed onto a different, stricter OpenAI-compatible backend is passed through
// rather than sanitized.
//
// Refs: Moonshot's tool_call_guidance.md; vLLM's "Debugging Kimi K2 Tool-Calling" writeup.
const KIMI_NATIVE_TOOL_CALL_ID = /^functions\.[A-Za-z0-9_-]+:\d+$/;

/**
* Preserve a canonical native id verbatim; sanitize every other id.
*
* A preserved id is never length-capped: the chat-completions `tool_call.id` has no limit in the
* OpenAI schema (the 64-char cap applies to the function *name*, `^[a-zA-Z0-9_-]{1,64}$`), and a
* long tool name or a multi-digit `idx` must still round-trip intact. Hence `fallbackMaxLength`,
* which bounds the sanitized fallback only — deliberately unlike
* `sanitizeOpenAIResponsesCallIdPreservingNative`, whose cap is a real wire limit and therefore
* also gates preservation.
*/
export function sanitizeToolCallIdPreservingNative(
id: string,
fallbackMaxLength?: number,
): string {
return KIMI_NATIVE_TOOL_CALL_ID.test(id) ? id : sanitizeToolCallId(id, fallbackMaxLength);
}

function isPreservableResponsesCallId(id: string, maxLength?: number): boolean {
return KIMI_NATIVE_TOOL_CALL_ID.test(id) && (maxLength === undefined || id.length <= maxLength);
}

export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string {
const [callId] = id.split('|', 1);
return sanitizeToolCallId(callId ?? id, maxLength);
}

/**
* Preserve a canonical native id verbatim, but only when it fits `maxLength`; sanitize every other
* id.
*
* Unlike the chat `tool_call.id`, the Responses `call_id` carries a real `maxLength` (64) on the
* wire, and a preserved id is sent untruncated — so an over-long canonical id must fall back to
* normalization rather than be rejected by the API.
*/
export function sanitizeOpenAIResponsesCallIdPreservingNative(
id: string,
maxLength?: number,
): string {
// Mirror `sanitizeOpenAIResponsesCallId` and drop the `|<itemId>` suffix first, so a canonical
// native id survives even when the Responses transport has appended an item id.
const [callId] = id.split('|', 1);
const base = callId ?? id;
return isPreservableResponsesCallId(base, maxLength)
? base
: sanitizeOpenAIResponsesCallId(id, maxLength);
}

export function normalizeToolCallIdsForProvider(
messages: Message[],
policy: ToolCallIdPolicy,
Expand Down
Loading