mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 19:27:26 +00:00
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:
@@ -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 " };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user