diff --git a/.changeset/tool-call-id-normalization.md b/.changeset/tool-call-id-normalization.md new file mode 100644 index 0000000000..1be7a37374 --- /dev/null +++ b/.changeset/tool-call-id-normalization.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix cross-provider tool call ID normalization when replaying tool history. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 06e5b6c912..b6adb13178 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -44,6 +44,11 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from './request-auth'; +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, + type ToolCallIdPolicy, +} from './tool-call-id'; /** * Normalize an Anthropic `stop_reason` string to the unified @@ -109,6 +114,10 @@ const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const FAMILY_VERSION_RE = /(?:opus|sonnet|haiku)[.-](\d+)[.-](\d{1,2})(?!\d)/; const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; +const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, +}; /** * Per-version default output ceilings sourced from Anthropic's Messages @@ -903,7 +912,11 @@ export class AnthropicChatProvider implements ChatProvider { // Convert messages, merging consecutive tool-result-only user messages // into a single user message (Anthropic parallel-tool-use spec). const messages: MessageParam[] = []; - for (const msg of history) { + const normalizedHistory = normalizeToolCallIdsForProvider( + history, + ANTHROPIC_TOOL_CALL_ID_POLICY, + ); + for (const msg of normalizedHistory) { const converted = convertMessage(msg); const last = messages.at(-1); if (last !== undefined && isToolResultOnly(last) && isToolResultOnly(converted)) { diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 77a7239f60..ef53eca7c1 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -35,6 +35,11 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from './request-auth'; +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, + type ToolCallIdPolicy, +} from './tool-call-id'; export interface KimiOptions { apiKey?: string | undefined; baseUrl?: string | undefined; @@ -77,6 +82,10 @@ export interface ExtraBody { thinking?: ThinkingConfig; [key: string]: unknown; } +const KIMI_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, +}; interface OpenAIMessage { role: string; content?: string | OpenAIContentPart[] | undefined; @@ -426,7 +435,8 @@ export class KimiChatProvider implements ChatProvider { if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } - for (const msg of history) { + const normalizedHistory = normalizeToolCallIdsForProvider(history, KIMI_TOOL_CALL_ID_POLICY); + for (const msg of normalizedHistory) { messages.push(convertMessage(msg)); } diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 7254dae79c..e050e9ede5 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -35,12 +35,21 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from './request-auth'; +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, + type ToolCallIdPolicy, +} from './tool-call-id'; // Inbound: scan in priority order; first string value wins. Outbound: the first // entry doubles as the default field we serialize ThinkPart back into. Both // arms can be overridden by an explicit `reasoningKey` on the provider config. const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const; const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0]; +const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, +}; function extractReasoningContent( source: unknown, @@ -397,7 +406,11 @@ export class OpenAILegacyChatProvider implements ChatProvider { if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } - for (const msg of history) { + const normalizedHistory = normalizeToolCallIdsForProvider( + history, + OPENAI_CHAT_TOOL_CALL_ID_POLICY, + ); + for (const msg of normalizedHistory) { messages.push(convertMessage(msg, this._reasoningKey, this._toolMessageConversion)); } diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 336da282c1..59c0c852da 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -29,6 +29,11 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from './request-auth'; +import { + normalizeToolCallIdsForProvider, + sanitizeOpenAIResponsesCallId, + type ToolCallIdPolicy, +} from './tool-call-id'; /** * Normalize the Responses API status / incomplete_details into the unified @@ -68,6 +73,10 @@ function normalizeResponsesFinishReason( } type RawObject = Record; +const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), + maxLength: 64, +}; type ResponseOutputItemView = | { @@ -904,7 +913,11 @@ export class OpenAIResponsesChatProvider implements ChatProvider { input.push(sysItem); } - for (const msg of history) { + const normalizedHistory = normalizeToolCallIdsForProvider( + history, + OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, + ); + for (const msg of normalizedHistory) { input.push(...convertMessage(msg, this._model, this._toolMessageConversion)); } diff --git a/packages/kosong/src/providers/tool-call-id.ts b/packages/kosong/src/providers/tool-call-id.ts new file mode 100644 index 0000000000..fd70267182 --- /dev/null +++ b/packages/kosong/src/providers/tool-call-id.ts @@ -0,0 +1,132 @@ +import type { Message, ToolCall } from '#/message'; + +export interface ToolCallIdPolicy { + normalize: (id: string) => string; + maxLength?: number; +} + +const EMPTY_TOOL_CALL_ID = 'tool_call'; +const TOOL_CALL_ID_SAFE_CHARS = /[^a-zA-Z0-9_-]/g; + +export function sanitizeToolCallId(id: string, maxLength?: number): string { + const sanitized = id.replace(TOOL_CALL_ID_SAFE_CHARS, '_'); + return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength); +} + +export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string { + const [callId] = id.split('|', 1); + return sanitizeToolCallId(callId ?? id, maxLength); +} + +export function normalizeToolCallIdsForProvider( + messages: Message[], + policy: ToolCallIdPolicy, +): Message[] { + const rawIds = collectToolCallIds(messages); + if (rawIds.length === 0) return messages; + + const mappedIds = buildToolCallIdMap(rawIds, policy); + let changed = false; + const normalizedMessages = messages.map((message) => { + let messageChanged = false; + let toolCalls = message.toolCalls; + + if (message.toolCalls.length > 0) { + toolCalls = message.toolCalls.map((toolCall) => { + const mappedId = mappedIds.get(toolCall.id); + if (mappedId === undefined || mappedId === toolCall.id) return toolCall; + messageChanged = true; + return { ...toolCall, id: mappedId } satisfies ToolCall; + }); + } + + const toolCallId = + message.toolCallId === undefined ? undefined : mappedIds.get(message.toolCallId); + const mappedToolCallId = toolCallId ?? message.toolCallId; + if (mappedToolCallId !== message.toolCallId) { + messageChanged = true; + } + + if (!messageChanged) return message; + changed = true; + return { ...message, toolCalls, toolCallId: mappedToolCallId }; + }); + + return changed ? normalizedMessages : messages; +} + +function collectToolCallIds(messages: Message[]): string[] { + const ids: string[] = []; + const seen = new Set(); + const append = (id: string): void => { + if (seen.has(id)) return; + seen.add(id); + ids.push(id); + }; + + for (const message of messages) { + for (const toolCall of message.toolCalls) { + append(toolCall.id); + } + if (message.toolCallId !== undefined) { + append(message.toolCallId); + } + } + + return ids; +} + +function buildToolCallIdMap( + rawIds: string[], + policy: ToolCallIdPolicy, +): Map { + const mappedIds = new Map(); + const usedIds = new Set(); + + for (const rawId of rawIds) { + const normalized = policy.normalize(rawId); + if (normalized === rawId && normalized.length > 0) { + mappedIds.set(rawId, normalized); + usedIds.add(normalized); + } + } + + for (const rawId of rawIds) { + if (mappedIds.has(rawId)) continue; + const normalized = policy.normalize(rawId); + const unique = makeUniqueToolCallId(normalized, usedIds, policy.maxLength); + mappedIds.set(rawId, unique); + usedIds.add(unique); + } + + return mappedIds; +} + +function makeUniqueToolCallId( + normalized: string, + usedIds: Set, + maxLength: number | undefined, +): string { + const base = normalized.length > 0 ? normalized : EMPTY_TOOL_CALL_ID; + const candidate = truncateToolCallId(base, maxLength, ''); + if (!usedIds.has(candidate)) return candidate; + + for (let i = 2; ; i++) { + const suffix = `_${i}`; + const suffixed = truncateToolCallId(base, maxLength, suffix); + if (!usedIds.has(suffixed)) return suffixed; + } +} + +function truncateToolCallId( + base: string, + maxLength: number | undefined, + suffix: string, +): string { + if (maxLength === undefined) return `${base}${suffix}`; + const baseLength = maxLength - suffix.length; + if (baseLength <= 0) { + throw new Error(`Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`); + } + return `${base.slice(0, baseLength)}${suffix}`; +} diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 5f40f9e638..0789204a49 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -321,6 +321,85 @@ describe('AnthropicChatProvider', () => { ]); }); + it('normalizes invalid historical tool call ids and matching tool results', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run tools' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'Write:6', + name: 'Write', + arguments: '{"path":"/tmp/b","content":"ok"}', + }, + { + type: 'function', + id: 'Write_6', + name: 'Write', + arguments: '{"path":"/tmp/a","content":"ok"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'wrote b' }], + toolCallId: 'Write:6', + toolCalls: [], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'wrote a' }], + toolCallId: 'Write_6', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Run tools' }], + }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'Write_6_2', + name: 'Write', + input: { path: '/tmp/b', content: 'ok' }, + }, + { + type: 'tool_use', + id: 'Write_6', + name: 'Write', + input: { path: '/tmp/a', content: 'ok' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'Write_6_2', + content: [{ type: 'text', text: 'wrote b' }], + }, + { + type: 'tool_result', + tool_use_id: 'Write_6', + content: [{ type: 'text', text: 'wrote a' }], + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + it('tool call with image result wraps image source inside tool_result', async () => { const provider = createProvider(); const toolCall: ToolCall = { diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index f72aff7d38..f309b2030e 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -358,6 +358,48 @@ describe('KimiChatProvider', () => { ]); }); + it('normalizes invalid historical tool call ids and matching tool results', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Read a file' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'Read:9', + name: 'Read', + arguments: '{"path":"/tmp/file"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'content' }], + toolCallId: 'Read:9', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Read a file' }, + { + role: 'assistant', + tool_calls: [ + { + type: 'function', + id: 'Read_9', + function: { name: 'Read', arguments: '{"path":"/tmp/file"}' }, + }, + ], + }, + { role: 'tool', content: 'content', tool_call_id: 'Read_9' }, + ]); + }); + it('tool call with image result', async () => { const provider = createProvider(); const toolCall: ToolCall = { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index f01c6aadc2..d51f8d8a6c 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -245,6 +245,48 @@ describe('OpenAILegacyChatProvider', () => { ]); }); + it('normalizes invalid historical tool call ids and matching tool results', async () => { + const provider = createProvider(); + 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 captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Run bash' }, + { + role: 'assistant', + tool_calls: [ + { + type: 'function', + id: 'Bash_7', + function: { name: 'Bash', arguments: '{"command":"pwd"}' }, + }, + ], + }, + { role: 'tool', content: '/tmp', tool_call_id: 'Bash_7' }, + ]); + }); + it('tool call with image result flattens to text to satisfy API constraints', async () => { // OpenAI Chat Completions `tool` messages only accept text content. // Even when toolMessageConversion is unset, a tool result containing diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index e3b69d075b..572527cba4 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -604,6 +604,46 @@ describe('OpenAIResponsesChatProvider', () => { }); }); + it('normalizes invalid historical tool call ids and matching function outputs', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'Bash:21', + name: 'Bash', + arguments: '{"command":"pwd"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: '/tmp' }], + toolCallId: 'Bash:21', + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const input = body['input'] as unknown[]; + + expect(input[1]).toEqual({ + arguments: '{"command":"pwd"}', + call_id: 'Bash_21', + name: 'Bash', + type: 'function_call', + }); + expect(input[2]).toEqual({ + call_id: 'Bash_21', + output: [{ type: 'input_text', text: '/tmp' }], + type: 'function_call_output', + }); + }); + it('assistant with reasoning (ThinkPart with encrypted)', async () => { const provider = createProvider(); const history: Message[] = [