From 21e69fdd4fa84b89184aa3592356d0823aedbb75 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 28 May 2026 19:18:41 +0100 Subject: [PATCH] fix(ollama): promote plain text tool calls Wrap Ollama native streams with the shared plain-text tool-call compatibility wrapper so local/plain-text tool requests are delivered as structured toolCall events when matching tools are available. Verified with live local Ollama proof, focused Testbox Vitest, Testbox check:changed, and autoreview. --- extensions/ollama/src/stream.test.ts | 109 ++++++++++++++++++++++++++- extensions/ollama/src/stream.ts | 10 ++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index 467475a9327..fb1302df543 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -106,6 +106,9 @@ describe("createOllamaStreamFn thinking events", () => { async function streamOllamaEvents( chunks: Array>, options: Parameters>[2] = {}, + context: Parameters>[1] = { + messages: [{ role: "user", content: "test" }], + } as never, ): Promise> { const body = makeNdjsonBody(chunks); fetchWithSsrFGuardMock.mockResolvedValue({ @@ -116,7 +119,7 @@ describe("createOllamaStreamFn thinking events", () => { const streamFn = createOllamaStreamFn("http://localhost:11434"); const stream = streamFn( { api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never, - { messages: [{ role: "user", content: "test" }] } as never, + context, options, ); @@ -249,4 +252,108 @@ describe("createOllamaStreamFn thinking events", () => { auditContext: "ollama-stream.chat", }); }); + + it("promotes standalone bracketed local-model tool text to a structured tool call", async () => { + const rawToolText = [ + "[mempalace_mempalace_search]", + '{"query":"codename","wing":"personal","room":"identities"}', + "[END_TOOL_REQUEST]", + ].join("\n"); + + const events = await streamOllamaEvents( + [ + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:00Z", + message: { role: "assistant", content: rawToolText }, + done: false, + }, + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:01Z", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + prompt_eval_count: 10, + eval_count: 5, + }, + ], + {}, + { + messages: [{ role: "user", content: "test" }], + tools: [ + { + name: "mempalace_mempalace_search", + description: "Search MemPalace", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "done", + ]); + const done = events.find((event) => event.type === "done") as { + message?: { content?: Array>; stopReason?: string }; + reason?: string; + }; + expect(done.reason).toBe("toolUse"); + expect(done.message?.stopReason).toBe("toolUse"); + expect(done.message?.content?.[0]).toMatchObject({ + type: "toolCall", + name: "mempalace_mempalace_search", + arguments: { query: "codename", wing: "personal", room: "identities" }, + }); + }); + + it("promotes standalone Harmony local-model tool text to a structured tool call", async () => { + const rawToolText = + 'commentary to=read code {"path":"/path/to/file","line_start":1,"line_end":400}'; + + const events = await streamOllamaEvents( + [ + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:00Z", + message: { role: "assistant", content: rawToolText }, + done: false, + }, + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:01Z", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + prompt_eval_count: 10, + eval_count: 5, + }, + ], + {}, + { + messages: [{ role: "user", content: "test" }], + tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }], + } as never, + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "done", + ]); + const done = events.find((event) => event.type === "done") as { + message?: { content?: Array>; stopReason?: string }; + reason?: string; + }; + expect(done.reason).toBe("toolUse"); + expect(done.message?.content?.[0]).toMatchObject({ + type: "toolCall", + name: "read", + arguments: { path: "/path/to/file", line_start: 1, line_end: 400 }, + }); + }); }); diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 5dea1279124..3541ae89ae6 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -23,6 +23,7 @@ import { } from "openclaw/plugin-sdk/provider-model-shared"; import { createMoonshotThinkingWrapper, + createPlainTextToolCallCompatWrapper, resolveMoonshotThinkingType, streamWithPayloadPatch, } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -1080,7 +1081,7 @@ function resolveOllamaRequestTimeoutMs( return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined; } -export function createOllamaStreamFn( +function createRawOllamaStreamFn( baseUrl: string, defaultHeaders?: Record, ): StreamFn { @@ -1409,6 +1410,13 @@ export function createOllamaStreamFn( }; } +export function createOllamaStreamFn( + baseUrl: string, + defaultHeaders?: Record, +): StreamFn { + return createPlainTextToolCallCompatWrapper(createRawOllamaStreamFn(baseUrl, defaultHeaders)); +} + export function createConfiguredOllamaStreamFn(params: { model: { baseUrl?: string; headers?: unknown }; providerBaseUrl?: string;