From 7b97af4899e9e24c0fd3f0678e814b2820117634 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:08:33 -0700 Subject: [PATCH] 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> --- extensions/ollama/src/stream-runtime.test.ts | 79 ++++++++++++++++++++ extensions/ollama/src/stream.ts | 68 +++++++++++++++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 886ee74d7a6..a5176643e96 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -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: {} }), ]); }); diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 4cb7a274029..e6348e736b1 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -758,7 +758,14 @@ function normalizeOllamaToolSchema(schema: unknown, isRoot = false): Record; +}; + +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 | undefined { + if (!tools || !Array.isArray(tools)) { + return undefined; + } + const names = new Set(); + 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();