Runner: normalize malformed tool call names before dispatch (#39328)

* Runner: normalize malformed tool call names before dispatch

* Runner: tighten prefixed tool name normalization
This commit is contained in:
Vincent Koc
2026-03-07 20:34:27 -05:00
committed by GitHub
parent ad80ecd445
commit 69a6c0a9dd
2 changed files with 108 additions and 18 deletions

View File

@@ -281,6 +281,76 @@ describe("wrapStreamFnTrimToolCallNames", () => {
expect(result).toBe(finalMessage);
});
it("maps provider-prefixed tool names to allowed canonical tools", async () => {
const partialToolCall = { type: "toolCall", name: " functions.read " };
const messageToolCall = { type: "toolCall", name: " functions.write " };
const finalToolCall = { type: "toolCall", name: " tools/exec " };
const event = {
type: "toolcall_delta",
partial: { role: "assistant", content: [partialToolCall] },
message: { role: "assistant", content: [messageToolCall] },
};
const { baseFn } = createEventStream({ event, finalToolCall });
const stream = await invokeWrappedStream(baseFn, new Set(["read", "write", "exec"]));
for await (const _item of stream) {
// drain
}
await stream.result();
expect(partialToolCall.name).toBe("read");
expect(messageToolCall.name).toBe("write");
expect(finalToolCall.name).toBe("exec");
});
it("normalizes toolUse and functionCall names before dispatch", async () => {
const partialToolCall = { type: "toolUse", name: " functions.read " };
const messageToolCall = { type: "functionCall", name: " functions.exec " };
const finalToolCall = { type: "toolUse", name: " tools/write " };
const event = {
type: "toolcall_delta",
partial: { role: "assistant", content: [partialToolCall] },
message: { role: "assistant", content: [messageToolCall] },
};
const finalMessage = { role: "assistant", content: [finalToolCall] };
const baseFn = vi.fn(() =>
createFakeStream({
events: [event],
resultMessage: finalMessage,
}),
);
const stream = await invokeWrappedStream(baseFn, new Set(["read", "write", "exec"]));
for await (const _item of stream) {
// drain
}
const result = await stream.result();
expect(partialToolCall.name).toBe("read");
expect(messageToolCall.name).toBe("exec");
expect(finalToolCall.name).toBe("write");
expect(result).toBe(finalMessage);
});
it("preserves multi-segment tool suffixes when dropping provider prefixes", async () => {
const finalToolCall = { type: "toolCall", name: " functions.graph.search " };
const finalMessage = { role: "assistant", content: [finalToolCall] };
const baseFn = vi.fn(() =>
createFakeStream({
events: [],
resultMessage: finalMessage,
}),
);
const stream = await invokeWrappedStream(baseFn, new Set(["graph.search", "search"]));
const result = await stream.result();
expect(finalToolCall.name).toBe("graph.search");
expect(result).toBe(finalMessage);
});
it("does not collapse whitespace-only tool names to empty strings", async () => {
const partialToolCall = { type: "toolCall", name: " " };
const finalToolCall = { type: "toolCall", name: "\t " };

View File

@@ -251,25 +251,45 @@ function normalizeToolCallNameForDispatch(rawName: string, allowedToolNames?: Se
if (!allowedToolNames || allowedToolNames.size === 0) {
return trimmed;
}
if (allowedToolNames.has(trimmed)) {
return trimmed;
}
const normalized = normalizeToolName(trimmed);
if (allowedToolNames.has(normalized)) {
return normalized;
}
const folded = trimmed.toLowerCase();
let caseInsensitiveMatch: string | null = null;
for (const name of allowedToolNames) {
if (name.toLowerCase() !== folded) {
continue;
const candidateNames = new Set<string>([trimmed, normalizeToolName(trimmed)]);
const normalizedDelimiter = trimmed.replace(/\//g, ".");
const segments = normalizedDelimiter
.split(".")
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length > 1) {
for (let index = 1; index < segments.length; index += 1) {
const suffix = segments.slice(index).join(".");
candidateNames.add(suffix);
candidateNames.add(normalizeToolName(suffix));
}
if (caseInsensitiveMatch && caseInsensitiveMatch !== name) {
return trimmed;
}
caseInsensitiveMatch = name;
}
return caseInsensitiveMatch ?? trimmed;
for (const candidate of candidateNames) {
if (allowedToolNames.has(candidate)) {
return candidate;
}
}
for (const candidate of candidateNames) {
const folded = candidate.toLowerCase();
let caseInsensitiveMatch: string | null = null;
for (const name of allowedToolNames) {
if (name.toLowerCase() !== folded) {
continue;
}
if (caseInsensitiveMatch && caseInsensitiveMatch !== name) {
return candidate;
}
caseInsensitiveMatch = name;
}
if (caseInsensitiveMatch) {
return caseInsensitiveMatch;
}
}
return trimmed;
}
function isToolCallBlockType(type: unknown): boolean {
@@ -361,7 +381,7 @@ function trimWhitespaceFromToolCallNamesInMessage(
continue;
}
const typedBlock = block as { type?: unknown; name?: unknown };
if (typedBlock.type !== "toolCall" || typeof typedBlock.name !== "string") {
if (!isToolCallBlockType(typedBlock.type) || typeof typedBlock.name !== "string") {
continue;
}
const normalized = normalizeToolCallNameForDispatch(typedBlock.name, allowedToolNames);