Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/tool-call-id-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
---

Fix cross-provider tool call ID normalization when replaying tool history.
15 changes: 14 additions & 1 deletion packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
12 changes: 11 additions & 1 deletion packages/kosong/src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}

Expand Down
15 changes: 14 additions & 1 deletion packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}

Expand Down
15 changes: 14 additions & 1 deletion packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +73,10 @@ function normalizeResponsesFinishReason(
}

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

type ResponseOutputItemView =
| {
Expand Down Expand Up @@ -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));
}

Expand Down
132 changes: 132 additions & 0 deletions packages/kosong/src/providers/tool-call-id.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, string> {
const mappedIds = new Map<string, string>();
const usedIds = new Set<string>();

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<string>,
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}`;
}
79 changes: 79 additions & 0 deletions packages/kosong/test/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading