From 0e1304d0de02ed6b29ad94e8daec5dfea8b765ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:45:32 -0700 Subject: [PATCH] fix(llama-cpp): recover plaintext tool calls (#116736) Co-authored-by: Peter Steinberger --- .../llama-cpp/src/inference-provider.test.ts | 94 +++++++++++++++++++ .../llama-cpp/src/inference-provider.ts | 5 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 8d41060a5829..c18d027fd74d 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -364,6 +364,100 @@ describe("llama.cpp inference provider", () => { expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled(); }); + it.each([ + { + format: "Harmony", + text: '<|channel|>commentary to=weather code<|message|>{"city":"Paris"}<|call|>', + }, + { + format: "bracketed", + text: '[weather]\n{"city":"Paris"}\n[END_TOOL_REQUEST]', + }, + ])("promotes $format plaintext tool calls into native tool events", async ({ text }) => { + mocks.generateResponse.mockImplementationOnce(async (_history, options) => { + options.onTextChunk(text.slice(0, 12)); + options.onTextChunk(text.slice(12)); + return { + response: text, + functionCalls: undefined, + metadata: { stopReason: "eogToken" }, + }; + }); + + const stream = await createLlamaCppStreamFn({})(model, { + messages: [{ role: "user", content: "Weather?", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + + const events = await collectEvents(stream); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events.at(-1)).toMatchObject({ + type: "done", + reason: "toolUse", + message: { + stopReason: "toolUse", + content: [ + { + type: "toolCall", + name: "weather", + arguments: { city: "Paris" }, + }, + ], + }, + }); + }); + + it("preserves plaintext calls for tools that are not registered", async () => { + const text = '[tool:calendar] {"city":"Paris"}'; + mocks.generateResponse.mockImplementationOnce(async (_history, options) => { + options.onTextChunk(text); + return { + response: text, + functionCalls: undefined, + metadata: { stopReason: "eogToken" }, + }; + }); + + const stream = await createLlamaCppStreamFn({})(model, { + messages: [{ role: "user", content: "Weather?", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + + const events = await collectEvents(stream); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events.at(-1)).toMatchObject({ + type: "done", + reason: "stop", + message: { content: [{ type: "text", text }] }, + }); + }); + it("lets tools win when responseFormat is also present", async () => { const stream = await createLlamaCppStreamFn({})( model, diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index e163f645c94a..0de0499bf706 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -17,6 +17,7 @@ import type { } from "openclaw/plugin-sdk/llm"; import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { DEFAULT_LLAMA_CPP_CONTEXT_SIZE, resolveLlamaCppModelCacheDir, @@ -293,7 +294,7 @@ async function clearLlamaCppInferenceCacheForTests(): Promise { } export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderConfig }): StreamFn { - return (model, context, options) => { + return createPlainTextToolCallCompatWrapper((model, context, options) => { const stream = createAssistantMessageEventStream(); let streamedText = ""; let generationAborted = false; @@ -453,7 +454,7 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC queueMicrotask(() => void serialize(run)); } return stream; - }; + }); } if (process.env.VITEST || process.env.NODE_ENV === "test") {