fix(llama-cpp): recover plaintext tool calls (#116736)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 02:45:32 -07:00
committed by GitHub
parent 1f578f0e65
commit 0e1304d0de
2 changed files with 97 additions and 2 deletions
@@ -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,
@@ -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<void> {
}
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") {