Skip to content

Commit 8809f3e

Browse files
authored
fix: normalize tool call ids across providers (#327)
1 parent fe7db4a commit 8809f3e

10 files changed

Lines changed: 394 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/kosong": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Fix cross-provider tool call ID normalization when replaying tool history.

packages/kosong/src/providers/anthropic.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ import {
4444
requireProviderApiKey,
4545
resolveAuthBackedClient,
4646
} from './request-auth';
47+
import {
48+
normalizeToolCallIdsForProvider,
49+
sanitizeToolCallId,
50+
type ToolCallIdPolicy,
51+
} from './tool-call-id';
4752

4853
/**
4954
* Normalize an Anthropic `stop_reason` string to the unified
@@ -109,6 +114,10 @@ const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
109114
const FAMILY_VERSION_RE = /(?:opus|sonnet|haiku)[.-](\d+)[.-](\d{1,2})(?!\d)/;
110115
const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/;
111116
const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const;
117+
const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
118+
normalize: (id) => sanitizeToolCallId(id, 64),
119+
maxLength: 64,
120+
};
112121

113122
/**
114123
* Per-version default output ceilings sourced from Anthropic's Messages
@@ -903,7 +912,11 @@ export class AnthropicChatProvider implements ChatProvider {
903912
// Convert messages, merging consecutive tool-result-only user messages
904913
// into a single user message (Anthropic parallel-tool-use spec).
905914
const messages: MessageParam[] = [];
906-
for (const msg of history) {
915+
const normalizedHistory = normalizeToolCallIdsForProvider(
916+
history,
917+
ANTHROPIC_TOOL_CALL_ID_POLICY,
918+
);
919+
for (const msg of normalizedHistory) {
907920
const converted = convertMessage(msg);
908921
const last = messages.at(-1);
909922
if (last !== undefined && isToolResultOnly(last) && isToolResultOnly(converted)) {

packages/kosong/src/providers/kimi.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import {
3535
requireProviderApiKey,
3636
resolveAuthBackedClient,
3737
} from './request-auth';
38+
import {
39+
normalizeToolCallIdsForProvider,
40+
sanitizeToolCallId,
41+
type ToolCallIdPolicy,
42+
} from './tool-call-id';
3843
export interface KimiOptions {
3944
apiKey?: string | undefined;
4045
baseUrl?: string | undefined;
@@ -77,6 +82,10 @@ export interface ExtraBody {
7782
thinking?: ThinkingConfig;
7883
[key: string]: unknown;
7984
}
85+
const KIMI_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
86+
normalize: (id) => sanitizeToolCallId(id, 64),
87+
maxLength: 64,
88+
};
8089
interface OpenAIMessage {
8190
role: string;
8291
content?: string | OpenAIContentPart[] | undefined;
@@ -426,7 +435,8 @@ export class KimiChatProvider implements ChatProvider {
426435
if (systemPrompt) {
427436
messages.push({ role: 'system', content: systemPrompt });
428437
}
429-
for (const msg of history) {
438+
const normalizedHistory = normalizeToolCallIdsForProvider(history, KIMI_TOOL_CALL_ID_POLICY);
439+
for (const msg of normalizedHistory) {
430440
messages.push(convertMessage(msg));
431441
}
432442

packages/kosong/src/providers/openai-legacy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,21 @@ import {
3535
requireProviderApiKey,
3636
resolveAuthBackedClient,
3737
} from './request-auth';
38+
import {
39+
normalizeToolCallIdsForProvider,
40+
sanitizeToolCallId,
41+
type ToolCallIdPolicy,
42+
} from './tool-call-id';
3843

3944
// Inbound: scan in priority order; first string value wins. Outbound: the first
4045
// entry doubles as the default field we serialize ThinkPart back into. Both
4146
// arms can be overridden by an explicit `reasoningKey` on the provider config.
4247
const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const;
4348
const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0];
49+
const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
50+
normalize: (id) => sanitizeToolCallId(id, 64),
51+
maxLength: 64,
52+
};
4453

4554
function extractReasoningContent(
4655
source: unknown,
@@ -397,7 +406,11 @@ export class OpenAILegacyChatProvider implements ChatProvider {
397406
if (systemPrompt) {
398407
messages.push({ role: 'system', content: systemPrompt });
399408
}
400-
for (const msg of history) {
409+
const normalizedHistory = normalizeToolCallIdsForProvider(
410+
history,
411+
OPENAI_CHAT_TOOL_CALL_ID_POLICY,
412+
);
413+
for (const msg of normalizedHistory) {
401414
messages.push(convertMessage(msg, this._reasoningKey, this._toolMessageConversion));
402415
}
403416

packages/kosong/src/providers/openai-responses.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ import {
2929
requireProviderApiKey,
3030
resolveAuthBackedClient,
3131
} from './request-auth';
32+
import {
33+
normalizeToolCallIdsForProvider,
34+
sanitizeOpenAIResponsesCallId,
35+
type ToolCallIdPolicy,
36+
} from './tool-call-id';
3237

3338
/**
3439
* Normalize the Responses API status / incomplete_details into the unified
@@ -68,6 +73,10 @@ function normalizeResponsesFinishReason(
6873
}
6974

7075
type RawObject = Record<string, unknown>;
76+
const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
77+
normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64),
78+
maxLength: 64,
79+
};
7180

7281
type ResponseOutputItemView =
7382
| {
@@ -904,7 +913,11 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
904913
input.push(sysItem);
905914
}
906915

907-
for (const msg of history) {
916+
const normalizedHistory = normalizeToolCallIdsForProvider(
917+
history,
918+
OPENAI_RESPONSES_TOOL_CALL_ID_POLICY,
919+
);
920+
for (const msg of normalizedHistory) {
908921
input.push(...convertMessage(msg, this._model, this._toolMessageConversion));
909922
}
910923

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { Message, ToolCall } from '#/message';
2+
3+
export interface ToolCallIdPolicy {
4+
normalize: (id: string) => string;
5+
maxLength?: number;
6+
}
7+
8+
const EMPTY_TOOL_CALL_ID = 'tool_call';
9+
const TOOL_CALL_ID_SAFE_CHARS = /[^a-zA-Z0-9_-]/g;
10+
11+
export function sanitizeToolCallId(id: string, maxLength?: number): string {
12+
const sanitized = id.replace(TOOL_CALL_ID_SAFE_CHARS, '_');
13+
return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength);
14+
}
15+
16+
export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string {
17+
const [callId] = id.split('|', 1);
18+
return sanitizeToolCallId(callId ?? id, maxLength);
19+
}
20+
21+
export function normalizeToolCallIdsForProvider(
22+
messages: Message[],
23+
policy: ToolCallIdPolicy,
24+
): Message[] {
25+
const rawIds = collectToolCallIds(messages);
26+
if (rawIds.length === 0) return messages;
27+
28+
const mappedIds = buildToolCallIdMap(rawIds, policy);
29+
let changed = false;
30+
const normalizedMessages = messages.map((message) => {
31+
let messageChanged = false;
32+
let toolCalls = message.toolCalls;
33+
34+
if (message.toolCalls.length > 0) {
35+
toolCalls = message.toolCalls.map((toolCall) => {
36+
const mappedId = mappedIds.get(toolCall.id);
37+
if (mappedId === undefined || mappedId === toolCall.id) return toolCall;
38+
messageChanged = true;
39+
return { ...toolCall, id: mappedId } satisfies ToolCall;
40+
});
41+
}
42+
43+
const toolCallId =
44+
message.toolCallId === undefined ? undefined : mappedIds.get(message.toolCallId);
45+
const mappedToolCallId = toolCallId ?? message.toolCallId;
46+
if (mappedToolCallId !== message.toolCallId) {
47+
messageChanged = true;
48+
}
49+
50+
if (!messageChanged) return message;
51+
changed = true;
52+
return { ...message, toolCalls, toolCallId: mappedToolCallId };
53+
});
54+
55+
return changed ? normalizedMessages : messages;
56+
}
57+
58+
function collectToolCallIds(messages: Message[]): string[] {
59+
const ids: string[] = [];
60+
const seen = new Set<string>();
61+
const append = (id: string): void => {
62+
if (seen.has(id)) return;
63+
seen.add(id);
64+
ids.push(id);
65+
};
66+
67+
for (const message of messages) {
68+
for (const toolCall of message.toolCalls) {
69+
append(toolCall.id);
70+
}
71+
if (message.toolCallId !== undefined) {
72+
append(message.toolCallId);
73+
}
74+
}
75+
76+
return ids;
77+
}
78+
79+
function buildToolCallIdMap(
80+
rawIds: string[],
81+
policy: ToolCallIdPolicy,
82+
): Map<string, string> {
83+
const mappedIds = new Map<string, string>();
84+
const usedIds = new Set<string>();
85+
86+
for (const rawId of rawIds) {
87+
const normalized = policy.normalize(rawId);
88+
if (normalized === rawId && normalized.length > 0) {
89+
mappedIds.set(rawId, normalized);
90+
usedIds.add(normalized);
91+
}
92+
}
93+
94+
for (const rawId of rawIds) {
95+
if (mappedIds.has(rawId)) continue;
96+
const normalized = policy.normalize(rawId);
97+
const unique = makeUniqueToolCallId(normalized, usedIds, policy.maxLength);
98+
mappedIds.set(rawId, unique);
99+
usedIds.add(unique);
100+
}
101+
102+
return mappedIds;
103+
}
104+
105+
function makeUniqueToolCallId(
106+
normalized: string,
107+
usedIds: Set<string>,
108+
maxLength: number | undefined,
109+
): string {
110+
const base = normalized.length > 0 ? normalized : EMPTY_TOOL_CALL_ID;
111+
const candidate = truncateToolCallId(base, maxLength, '');
112+
if (!usedIds.has(candidate)) return candidate;
113+
114+
for (let i = 2; ; i++) {
115+
const suffix = `_${i}`;
116+
const suffixed = truncateToolCallId(base, maxLength, suffix);
117+
if (!usedIds.has(suffixed)) return suffixed;
118+
}
119+
}
120+
121+
function truncateToolCallId(
122+
base: string,
123+
maxLength: number | undefined,
124+
suffix: string,
125+
): string {
126+
if (maxLength === undefined) return `${base}${suffix}`;
127+
const baseLength = maxLength - suffix.length;
128+
if (baseLength <= 0) {
129+
throw new Error(`Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`);
130+
}
131+
return `${base.slice(0, baseLength)}${suffix}`;
132+
}

packages/kosong/test/anthropic.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,85 @@ describe('AnthropicChatProvider', () => {
321321
]);
322322
});
323323

324+
it('normalizes invalid historical tool call ids and matching tool results', async () => {
325+
const provider = createProvider();
326+
const history: Message[] = [
327+
{ role: 'user', content: [{ type: 'text', text: 'Run tools' }], toolCalls: [] },
328+
{
329+
role: 'assistant',
330+
content: [],
331+
toolCalls: [
332+
{
333+
type: 'function',
334+
id: 'Write:6',
335+
name: 'Write',
336+
arguments: '{"path":"/tmp/b","content":"ok"}',
337+
},
338+
{
339+
type: 'function',
340+
id: 'Write_6',
341+
name: 'Write',
342+
arguments: '{"path":"/tmp/a","content":"ok"}',
343+
},
344+
],
345+
},
346+
{
347+
role: 'tool',
348+
content: [{ type: 'text', text: 'wrote b' }],
349+
toolCallId: 'Write:6',
350+
toolCalls: [],
351+
},
352+
{
353+
role: 'tool',
354+
content: [{ type: 'text', text: 'wrote a' }],
355+
toolCallId: 'Write_6',
356+
toolCalls: [],
357+
},
358+
];
359+
360+
const body = await captureRequestBody(provider, '', [], history);
361+
362+
expect(body['messages']).toEqual([
363+
{
364+
role: 'user',
365+
content: [{ type: 'text', text: 'Run tools' }],
366+
},
367+
{
368+
role: 'assistant',
369+
content: [
370+
{
371+
type: 'tool_use',
372+
id: 'Write_6_2',
373+
name: 'Write',
374+
input: { path: '/tmp/b', content: 'ok' },
375+
},
376+
{
377+
type: 'tool_use',
378+
id: 'Write_6',
379+
name: 'Write',
380+
input: { path: '/tmp/a', content: 'ok' },
381+
},
382+
],
383+
},
384+
{
385+
role: 'user',
386+
content: [
387+
{
388+
type: 'tool_result',
389+
tool_use_id: 'Write_6_2',
390+
content: [{ type: 'text', text: 'wrote b' }],
391+
},
392+
{
393+
type: 'tool_result',
394+
tool_use_id: 'Write_6',
395+
content: [{ type: 'text', text: 'wrote a' }],
396+
cache_control: { type: 'ephemeral' },
397+
},
398+
],
399+
},
400+
]);
401+
});
402+
324403
it('tool call with image result wraps image source inside tool_result', async () => {
325404
const provider = createProvider();
326405
const toolCall: ToolCall = {

0 commit comments

Comments
 (0)