Skip to content

Commit d4cd12c

Browse files
Thunderboltclaude
andcommitted
fix: normalize mangled tool names and IDs from OpenAI-compatible providers
Some OpenAI-compatible providers (like Kimi via openai-completions) send tool calls with IDs like "functions.exec:0" which get corrupted to "functions exec:0" (space instead of dot), and tool names like "exec" become "functions exec". This causes "Tool functions exec not found" errors because the tool result cannot be matched to the original tool call. Changes: - Add normalizePrefixedToolName() to strip "functions." or "functions " prefixes from tool names in session-transcript-repair.ts - Add normalizeMangledToolCallId() to fix "functions " -> "functions." in tool call IDs in tool-call-id.ts - Update extractToolResultId() to normalize mangled IDs before matching - Update sanitizeToolCallIdsForCloudCodeAssist() to use normalized IDs as map keys, ensuring "functions.exec:0" and "functions exec:0" map to the same sanitized value - Add comprehensive tests for the normalization behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4de697f commit d4cd12c

4 files changed

Lines changed: 207 additions & 12 deletions

File tree

src/agents/session-transcript-repair.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,54 @@ describe("sanitizeToolCallInputs", () => {
445445
expect((toolCalls[0] as { id?: unknown }).id).toBe("call_1");
446446
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" });
447447
});
448+
449+
it.each([
450+
{
451+
name: "strips 'functions.' prefix from tool names",
452+
content: [{ type: "toolCall", id: "call_1", name: "functions.read", arguments: {} }],
453+
expectedNames: ["read"],
454+
},
455+
{
456+
name: "strips 'functions.' prefix with trailing space",
457+
content: [{ type: "toolCall", id: "call_1", name: "functions. read", arguments: {} }],
458+
expectedNames: ["read"],
459+
},
460+
{
461+
name: "strips 'functions ' prefix (space instead of dot)",
462+
content: [{ type: "toolCall", id: "call_1", name: "functions read", arguments: {} }],
463+
expectedNames: ["read"],
464+
},
465+
{
466+
name: "strips 'functions ' prefix with multiple spaces",
467+
content: [{ type: "toolCall", id: "call_1", name: "functions read", arguments: {} }],
468+
expectedNames: ["read"],
469+
},
470+
{
471+
name: "handles mixed case 'Functions.' prefix",
472+
content: [{ type: "toolCall", id: "call_1", name: "Functions.exec", arguments: {} }],
473+
expectedNames: ["exec"],
474+
},
475+
{
476+
name: "handles 'FUNCTIONS ' uppercase prefix",
477+
content: [{ type: "toolCall", id: "call_1", name: "FUNCTIONS exec", arguments: {} }],
478+
expectedNames: ["exec"],
479+
},
480+
{
481+
name: "strips prefix and matches against allowlist",
482+
content: [
483+
{ type: "toolCall", id: "call_1", name: "functions.read", arguments: {} },
484+
{ type: "toolCall", id: "call_2", name: "functions.write", arguments: {} },
485+
],
486+
options: { allowedToolNames: ["read"] },
487+
expectedNames: ["read"],
488+
},
489+
])("$name", ({ content, options, expectedNames }) => {
490+
const toolCalls = sanitizeAssistantToolCalls(content, options);
491+
const names = toolCalls
492+
.map((toolCall) => (toolCall as { name?: unknown }).name)
493+
.filter((name): name is string => typeof name === "string");
494+
expect(names).toEqual(expectedNames);
495+
});
448496
});
449497

450498
describe("stripToolResultDetails", () => {

src/agents/session-transcript-repair.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-
44
const TOOL_CALL_NAME_MAX_CHARS = 64;
55
const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_-]+$/;
66

7+
// OpenAI-compatible providers may prefix tool names with "functions." or "functions "
8+
// during function calling. These prefixes need to be stripped to match actual tool names.
9+
const FUNCTIONS_PREFIX_RE = /^functions\.[\s]*/i;
10+
const FUNCTIONS_PREFIX_SPACE_RE = /^functions\s+/i;
11+
12+
/**
13+
* Normalize a tool name that may have been prefixed with "functions." or "functions "
14+
* by an OpenAI-compatible provider.
15+
*/
16+
function normalizePrefixedToolName(name: string): string {
17+
if (FUNCTIONS_PREFIX_RE.test(name)) {
18+
return name.replace(FUNCTIONS_PREFIX_RE, "");
19+
}
20+
if (FUNCTIONS_PREFIX_SPACE_RE.test(name)) {
21+
return name.replace(FUNCTIONS_PREFIX_SPACE_RE, "");
22+
}
23+
return name;
24+
}
25+
726
type RawToolCallBlock = {
827
type?: unknown;
928
id?: unknown;
@@ -63,13 +82,15 @@ function hasToolCallName(block: RawToolCallBlock, allowedToolNames: Set<string>
6382
if (!trimmed) {
6483
return false;
6584
}
66-
if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) {
85+
// Normalize away any "functions." or "functions " prefix added by OpenAI-compatible providers
86+
const normalizedName = normalizePrefixedToolName(trimmed);
87+
if (normalizedName.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(normalizedName)) {
6788
return false;
6889
}
6990
if (!allowedToolNames) {
7091
return true;
7192
}
72-
return allowedToolNames.has(trimmed.toLowerCase());
93+
return allowedToolNames.has(normalizedName.toLowerCase());
7394
}
7495

7596
function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
@@ -99,8 +120,10 @@ function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
99120
const rawName = typeof block.name === "string" ? block.name : undefined;
100121
const trimmedName = rawName?.trim();
101122
const hasTrimmedName = typeof trimmedName === "string" && trimmedName.length > 0;
102-
const normalizedName = hasTrimmedName ? trimmedName : undefined;
103-
const nameChanged = hasTrimmedName && rawName !== trimmedName;
123+
// Normalize away any "functions." or "functions " prefix added by OpenAI-compatible providers
124+
const prefixNormalizedName = hasTrimmedName ? normalizePrefixedToolName(trimmedName) : undefined;
125+
const normalizedName = prefixNormalizedName;
126+
const nameChanged = hasTrimmedName && rawName !== normalizedName;
104127

105128
const isSessionsSpawn = normalizedName?.toLowerCase() === "sessions_spawn";
106129

@@ -164,9 +187,16 @@ function normalizeToolResultName(
164187
fallbackName?: string,
165188
): Extract<AgentMessage, { role: "toolResult" }> {
166189
const rawToolName = (message as { toolName?: unknown }).toolName;
167-
const normalizedToolName = trimNonEmptyString(rawToolName);
190+
const trimmedToolName = trimNonEmptyString(rawToolName);
191+
// Normalize away any "functions." or "functions " prefix added by OpenAI-compatible providers
192+
const normalizedToolName = trimmedToolName
193+
? normalizePrefixedToolName(trimmedToolName)
194+
: undefined;
168195
if (normalizedToolName) {
169-
if (rawToolName === normalizedToolName) {
196+
// Also normalize the raw tool name if it had a prefix
197+
const finalRawName =
198+
typeof rawToolName === "string" ? normalizePrefixedToolName(rawToolName.trim()) : rawToolName;
199+
if (finalRawName === normalizedToolName) {
170200
return message;
171201
}
172202
return { ...message, toolName: normalizedToolName };
@@ -276,8 +306,10 @@ export function repairToolCallInputs(
276306
if (typeof (block as { name?: unknown }).name === "string") {
277307
const rawName = (block as { name: string }).name;
278308
const trimmedName = rawName.trim();
279-
if (rawName !== trimmedName && trimmedName) {
280-
const renamed = { ...(block as object), name: trimmedName } as typeof block;
309+
// Normalize away any "functions." or "functions " prefix added by OpenAI-compatible providers
310+
const normalizedName = normalizePrefixedToolName(trimmedName);
311+
if (rawName !== normalizedName && normalizedName) {
312+
const renamed = { ...(block as object), name: normalizedName } as typeof block;
281313
nextContent.push(renamed);
282314
changed = true;
283315
messageChanged = true;

src/agents/tool-call-id.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,101 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
232232
expect(bId.length).toBe(9);
233233
});
234234
});
235+
236+
describe("mangled tool call ID normalization", () => {
237+
it("normalizes mangled tool call IDs with 'functions ' instead of 'functions.'", () => {
238+
// Some OpenAI-compatible providers send IDs like "functions.exec:0" which get
239+
// corrupted to "functions exec:0" (space instead of dot).
240+
// The key behavior is that the tool result ID gets normalized to match the tool call ID.
241+
const input = castAgentMessages([
242+
{
243+
role: "assistant",
244+
content: [{ type: "toolCall", id: "functions.exec:0", name: "exec", arguments: {} }],
245+
},
246+
{
247+
role: "toolResult",
248+
// Mangled ID: space instead of dot - should be normalized to match tool call
249+
toolCallId: "functions exec:0",
250+
toolName: "exec",
251+
content: [{ type: "text", text: "ok" }],
252+
},
253+
]);
254+
255+
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
256+
// The tool call ID should be sanitized to alphanumeric
257+
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
258+
const toolCall = assistant.content?.[0] as { id?: string };
259+
const sanitizedId = toolCall.id;
260+
expect(sanitizedId).toMatch(/^functionsexec0/); // May have hash suffix if collision occurred
261+
262+
// The tool result should match the normalized tool call ID
263+
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
264+
expect(result.toolCallId).toBe(sanitizedId);
265+
});
266+
267+
it("normalizes mangled tool call IDs with various patterns", () => {
268+
const input = castAgentMessages([
269+
{
270+
role: "assistant",
271+
content: [
272+
{ type: "toolCall", id: "functions.read:1", name: "read", arguments: {} },
273+
{ type: "toolCall", id: "functions.exec:2", name: "exec", arguments: {} },
274+
],
275+
},
276+
{
277+
role: "toolResult",
278+
toolCallId: "functions read:1",
279+
toolName: "read",
280+
content: [{ type: "text", text: "one" }],
281+
},
282+
{
283+
role: "toolResult",
284+
toolCallId: "functions exec:2",
285+
toolName: "exec",
286+
content: [{ type: "text", text: "two" }],
287+
},
288+
]);
289+
290+
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
291+
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
292+
const call1 = assistant.content?.[0] as { id?: string };
293+
const call2 = assistant.content?.[1] as { id?: string };
294+
295+
// IDs should be sanitized (alphanumeric only)
296+
expect(call1.id).toMatch(/^functionsread1/);
297+
expect(call2.id).toMatch(/^functionsexec2/);
298+
299+
// Tool results should match their corresponding tool calls
300+
const result1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
301+
const result2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
302+
expect(result1.toolCallId).toBe(call1.id);
303+
expect(result2.toolCallId).toBe(call2.id);
304+
});
305+
306+
it("handles tool results that come before their tool calls in the message array", () => {
307+
// Edge case: tool results might appear before assistant messages in some scenarios
308+
const input = castAgentMessages([
309+
{
310+
role: "toolResult",
311+
// Mangled ID
312+
toolCallId: "functions exec:0",
313+
toolName: "exec",
314+
content: [{ type: "text", text: "ok" }],
315+
},
316+
{
317+
role: "assistant",
318+
content: [{ type: "toolCall", id: "functions.exec:0", name: "exec", arguments: {} }],
319+
},
320+
]);
321+
322+
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
323+
324+
// Both IDs should be normalized and match
325+
const assistant = out[1] as Extract<AgentMessage, { role: "assistant" }>;
326+
const toolCall = assistant.content?.[0] as { id?: string };
327+
const result = out[0] as Extract<AgentMessage, { role: "toolResult" }>;
328+
329+
expect(result.toolCallId).toBe(toolCall.id);
330+
});
331+
});
235332
});

src/agents/tool-call-id.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,27 @@ export function extractToolCallsFromAssistant(
6868
return toolCalls;
6969
}
7070

71+
// OpenAI-compatible providers may send tool call IDs like "functions.exec:0"
72+
// which can get mangled to "functions exec:0" (dot replaced with space).
73+
// This regex detects and fixes that pattern.
74+
const MANGLED_TOOL_CALL_ID_RE = /^(functions)\s+([a-zA-Z0-9_-]+):/i;
75+
76+
/**
77+
* Normalize a potentially mangled tool call ID.
78+
* Some OpenAI-compatible providers send IDs like "functions.exec:0" which get
79+
* corrupted to "functions exec:0" (space instead of dot).
80+
*/
81+
function normalizeMangledToolCallId(id: string): string {
82+
return id.replace(MANGLED_TOOL_CALL_ID_RE, "$1.$2:");
83+
}
84+
7185
export function extractToolResultId(
7286
msg: Extract<AgentMessage, { role: "toolResult" }>,
7387
): string | null {
7488
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
7589
if (typeof toolCallId === "string" && toolCallId) {
76-
return toolCallId;
90+
// Normalize potentially mangled tool call IDs
91+
return normalizeMangledToolCallId(toolCallId);
7792
}
7893
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
7994
if (typeof toolUseId === "string" && toolUseId) {
@@ -225,12 +240,15 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
225240
const used = new Set<string>();
226241

227242
const resolve = (id: string) => {
228-
const existing = map.get(id);
243+
// Normalize mangled IDs before lookup to ensure "functions.exec:0" and "functions exec:0"
244+
// are treated as the same key and map to the same sanitized value.
245+
const normalizedId = normalizeMangledToolCallId(id);
246+
const existing = map.get(normalizedId);
229247
if (existing) {
230248
return existing;
231249
}
232-
const next = makeUniqueToolId({ id, used, mode });
233-
map.set(id, next);
250+
const next = makeUniqueToolId({ id: normalizedId, used, mode });
251+
map.set(normalizedId, next);
234252
used.add(next);
235253
return next;
236254
};

0 commit comments

Comments
 (0)