fix: Found one narrow regression risk in the new Ollama tool-call name (#74563)

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-04-29 14:08:33 -07:00
committed by GitHub
parent 6378de91e7
commit 7b97af4899
2 changed files with 139 additions and 8 deletions

View File

@@ -577,6 +577,48 @@ describe("convertToOllamaMessages", () => {
]);
});
it("preserves exact allowlisted tool-prefix names before Ollama replay", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: "tool_a", arguments: { value: 1 } },
{ type: "tool_use", id: "call_2", name: "tools_invoke_test", input: { value: 2 } },
{ type: "toolCall", id: "call_3", name: "function-run", arguments: { value: 3 } },
],
},
];
const result = convertToOllamaMessages(messages, undefined, {
availableToolNames: new Set(["tool_a", "tools_invoke_test", "function-run"]),
});
expect(result[0].tool_calls).toEqual([
{ function: { name: "tool_a", arguments: { value: 1 } } },
{ function: { name: "tools_invoke_test", arguments: { value: 2 } } },
{ function: { name: "function-run", arguments: { value: 3 } } },
]);
});
it("strips underscore and dash provider prefixes only when the suffix is allowlisted", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: "tools_exec", arguments: { command: "pwd" } },
{ type: "tool_use", id: "call_2", name: "function-read", input: { path: "." } },
{ type: "toolCall", id: "call_3", name: "tool_missing", arguments: {} },
],
},
];
const result = convertToOllamaMessages(messages, undefined, {
availableToolNames: new Set(["exec", "read"]),
});
expect(result[0].tool_calls).toEqual([
{ function: { name: "exec", arguments: { command: "pwd" } } },
{ function: { name: "read", arguments: { path: "." } } },
{ function: { name: "tool_missing", arguments: {} } },
]);
});
it("keeps non-prefixed Ollama replay tool names intact", () => {
const messages = [
{
@@ -585,6 +627,7 @@ describe("convertToOllamaMessages", () => {
{ type: "toolCall", id: "call_1", name: "functionshell", arguments: {} },
{ type: "toolCall", id: "call_2", name: "tooling", arguments: {} },
{ type: "toolCall", id: "call_3", name: "tools", arguments: {} },
{ type: "toolCall", id: "call_4", name: "tool_a", arguments: {} },
],
},
];
@@ -593,6 +636,7 @@ describe("convertToOllamaMessages", () => {
{ function: { name: "functionshell", arguments: {} } },
{ function: { name: "tooling", arguments: {} } },
{ function: { name: "tools", arguments: {} } },
{ function: { name: "tool_a", arguments: {} } },
]);
});
@@ -825,6 +869,39 @@ describe("buildAssistantMessage", () => {
]);
});
it("preserves exact allowlisted tool-prefix names in Ollama responses", () => {
const response = {
model: "qwen3:32b",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content: "",
tool_calls: [
{ function: { name: "tool_a", arguments: { value: 1 } } },
{ function: { name: "tools_invoke_test", arguments: { value: 2 } } },
{ function: { name: "function-run", arguments: { value: 3 } } },
],
},
done: true,
};
const result = buildAssistantMessage(response, modelInfo, undefined, {
availableToolNames: new Set(["tool_a", "tools_invoke_test", "function-run"]),
});
expect(result.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "tool_a", arguments: { value: 1 } }),
expect.objectContaining({
type: "toolCall",
name: "tools_invoke_test",
arguments: { value: 2 },
}),
expect.objectContaining({
type: "toolCall",
name: "function-run",
arguments: { value: 3 },
}),
]);
});
it("keeps non-prefixed Ollama response tool names intact", () => {
const response = {
model: "qwen3:32b",
@@ -836,6 +913,7 @@ describe("buildAssistantMessage", () => {
{ function: { name: "functionshell", arguments: {} } },
{ function: { name: "tooling", arguments: {} } },
{ function: { name: "tools", arguments: {} } },
{ function: { name: "tool_a", arguments: {} } },
],
},
done: true,
@@ -845,6 +923,7 @@ describe("buildAssistantMessage", () => {
expect.objectContaining({ type: "toolCall", name: "functionshell", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tooling", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tools", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tool_a", arguments: {} }),
]);
});

View File

@@ -758,7 +758,14 @@ function normalizeOllamaToolSchema(schema: unknown, isRoot = false): Record<stri
return normalized;
}
function extractToolCalls(content: unknown): OllamaToolCall[] {
type OllamaToolCallNameOptions = {
availableToolNames?: ReadonlySet<string>;
};
function extractToolCalls(
content: unknown,
options: OllamaToolCallNameOptions = {},
): OllamaToolCall[] {
if (!Array.isArray(content)) {
return [];
}
@@ -768,14 +775,14 @@ function extractToolCalls(content: unknown): OllamaToolCall[] {
if (part.type === "toolCall") {
result.push({
function: {
name: normalizeOllamaToolCallName(part.name),
name: normalizeOllamaToolCallName(part.name, options),
arguments: ensureArgsObject(part.arguments),
},
});
} else if (part.type === "tool_use") {
result.push({
function: {
name: normalizeOllamaToolCallName(part.name),
name: normalizeOllamaToolCallName(part.name, options),
arguments: ensureArgsObject(part.input),
},
});
@@ -784,17 +791,51 @@ function extractToolCalls(content: unknown): OllamaToolCall[] {
return result;
}
function normalizeOllamaToolCallName(rawName: string): string {
function buildOllamaToolNameSet(tools: Tool[] | undefined): ReadonlySet<string> | undefined {
if (!tools || !Array.isArray(tools)) {
return undefined;
}
const names = new Set<string>();
for (const tool of tools) {
if (typeof tool.name === "string" && tool.name.trim()) {
names.add(tool.name.trim());
}
}
return names.size > 0 ? names : undefined;
}
function normalizeOllamaToolCallName(
rawName: string,
options: OllamaToolCallNameOptions = {},
): string {
const trimmed = rawName.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.replace(/^(?:functions?|tools?)[./_-]+/iu, "").trim();
const availableToolNames = options.availableToolNames;
if (availableToolNames?.has(trimmed)) {
return trimmed;
}
const strippedAnySeparator = trimmed.replace(/^(?:functions?|tools?)[./_-]+/iu, "").trim();
if (
availableToolNames &&
strippedAnySeparator !== trimmed &&
availableToolNames.has(strippedAnySeparator)
) {
return strippedAnySeparator;
}
if (availableToolNames) {
return trimmed;
}
return trimmed.replace(/^(?:functions?|tools?)[./]+/iu, "").trim();
}
export function convertToOllamaMessages(
messages: Array<{ role: string; content: unknown }>,
system?: string,
options: OllamaToolCallNameOptions = {},
): OllamaChatMessage[] {
const result: OllamaChatMessage[] = [];
@@ -816,7 +857,7 @@ export function convertToOllamaMessages(
if (msg.role === "assistant") {
const text = extractTextContent(msg.content);
const toolCalls = extractToolCalls(msg.content);
const toolCalls = extractToolCalls(msg.content, options);
result.push({
role: "assistant",
content: text,
@@ -867,6 +908,7 @@ export function buildAssistantMessage(
response: OllamaChatResponse,
modelInfo: StreamModelDescriptor,
usageFallback?: OllamaUsageFallback,
options: OllamaToolCallNameOptions = {},
): AssistantMessage {
const content: (TextContent | ThinkingContent | ToolCall)[] = [];
const thinking = response.message.thinking ?? response.message.reasoning ?? "";
@@ -884,7 +926,7 @@ export function buildAssistantMessage(
content.push({
type: "toolCall",
id: `ollama_call_${randomUUID()}`,
name: normalizeOllamaToolCallName(toolCall.function.name),
name: normalizeOllamaToolCallName(toolCall.function.name, options),
arguments: normalizeOllamaToolCallArguments(toolCall.function.arguments),
});
}
@@ -976,9 +1018,14 @@ export function createOllamaStreamFn(
const run = async () => {
try {
const availableToolNames = buildOllamaToolNameSet(context.tools);
const toolCallNameOptions: OllamaToolCallNameOptions = availableToolNames
? { availableToolNames }
: {};
const ollamaMessages = convertToOllamaMessages(
context.messages ?? [],
context.systemPrompt,
toolCallNameOptions,
);
const ollamaTools = extractOllamaTools(context.tools);
@@ -1214,7 +1261,12 @@ export function createOllamaStreamFn(
input: estimateOllamaPromptTokens({ messages: ollamaMessages, tools: ollamaTools }),
output: estimateOllamaCompletionTokens(finalResponse),
};
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo, usageFallback);
const assistantMessage = buildAssistantMessage(
finalResponse,
modelInfo,
usageFallback,
toolCallNameOptions,
);
closeThinkingBlock();
closeTextBlock();