From ba2b0337742e3c07dbaa3fdfcf9b4f6a872a18c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 9 May 2026 09:18:19 +0100 Subject: [PATCH] fix(plugin-sdk): parse harmony text tool calls --- CHANGELOG.md | 1 + extensions/lmstudio/src/stream.test.ts | 43 +++++++++++++ extensions/lmstudio/src/stream.ts | 9 ++- src/plugin-sdk/tool-payload.test.ts | 67 +++++++++++++++++++- src/plugin-sdk/tool-payload.ts | 85 +++++++++++++++++++++++--- 5 files changed, 195 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333c6255f68..ca8c4cf9280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ Docs: https://docs.openclaw.ai - OpenAI/realtime voice: defer `response.create` while a realtime response is still active, retry after `response.done`/`response.cancelled`, and align GA input transcription/noise-reduction defaults with the Codex realtime reference so Discord/Voice Call consult results can resume speaking instead of tripping the active-response race. - Gateway: avoid false degraded event-loop health during rapid health/readiness/status probes unless sustained load has delay co-evidence, while keeping hard delay detection immediate. (#77028) Thanks @rubencu. - Markdown: keep blockquote spans off trailing paragraph separators. Fixes #79646. +- Plugin SDK/LM Studio: recover Harmony plain-text tool calls from LM Studio streams. Fixes #78326. - Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu. - Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66. - Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou. diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 4413e53acbc..c5976c17b76 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -519,6 +519,49 @@ describe("lmstudio stream wrapper", () => { expect(String(done.message?.content?.[0]?.id)).toMatch(/^call_[a-f0-9]{24}$/); }); + 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 baseStream = buildEventStreamFn([ + { type: "start", partial: { content: [] } }, + { type: "text_start", contentIndex: 0, partial: { content: [{ type: "text", text: "" }] } }, + { type: "text_delta", contentIndex: 0, delta: rawToolText }, + { type: "text_end", contentIndex: 0, content: rawToolText }, + { + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: rawToolText }], + stopReason: "stop", + }, + }, + ]); + const wrapped = createWrappedLmstudioStream(baseStream); + const events = await collectEvents( + runWrappedLmstudioStream(wrapped, {}, undefined, { + tools: [{ name: "read", description: "Read", parameters: { type: "object" } }], + }), + ); + + 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 }, + }); + }); + it("passes through bracketed text when the tool is not registered", async () => { const rawToolText = [ "[mempalace_mempalace_search]", diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index c94926f3b25..08544c968ff 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -156,7 +156,14 @@ function couldStillBePlainTextToolCall(text: string): boolean { return false; } const trimmed = text.trimStart(); - return trimmed.length === 0 || trimmed.startsWith("["); + return ( + trimmed.length === 0 || + trimmed.startsWith("[") || + trimmed.startsWith("<|channel|>") || + trimmed.startsWith("commentary") || + trimmed.startsWith("analysis") || + trimmed.startsWith("final") + ); } function createLmstudioToolCallBlock(parsed: { diff --git a/src/plugin-sdk/tool-payload.test.ts b/src/plugin-sdk/tool-payload.test.ts index 8401206ae4c..030b9d6da71 100644 --- a/src/plugin-sdk/tool-payload.test.ts +++ b/src/plugin-sdk/tool-payload.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractToolPayload, type ToolPayloadCarrier } from "./tool-payload.js"; +import { + extractToolPayload, + parseStandalonePlainTextToolCallBlocks, + stripPlainTextToolCallBlocks, + type ToolPayloadCarrier, +} from "./tool-payload.js"; describe("extractToolPayload", () => { it("returns undefined for missing results", () => { @@ -43,3 +48,63 @@ describe("extractToolPayload", () => { expect(extractToolPayload(result)).toBe(result); }); }); + +describe("parseStandalonePlainTextToolCallBlocks", () => { + it("parses bracketed local-model tool blocks", () => { + const blocks = parseStandalonePlainTextToolCallBlocks( + ["[read]", '{"path":"/tmp/file.txt","line_start":1}', "[END_TOOL_REQUEST]"].join("\n"), + ); + + expect(blocks).toMatchObject([ + { + name: "read", + arguments: { path: "/tmp/file.txt", line_start: 1 }, + }, + ]); + }); + + it("parses Harmony commentary tool calls", () => { + const blocks = parseStandalonePlainTextToolCallBlocks( + 'commentary to=read code {"path":"/path/to/file","line_start":1,"line_end":400}', + ); + + expect(blocks).toMatchObject([ + { + name: "read", + arguments: { path: "/path/to/file", line_start: 1, line_end: 400 }, + }, + ]); + }); + + it("parses Harmony marker-wrapped tool calls", () => { + const blocks = parseStandalonePlainTextToolCallBlocks( + '<|channel|>commentary to=read code<|message|>{"path":"/tmp/file.txt"}<|call|>', + ); + + expect(blocks).toMatchObject([ + { + name: "read", + arguments: { path: "/tmp/file.txt" }, + }, + ]); + }); + + it("respects allowed tool names for Harmony calls", () => { + const blocks = parseStandalonePlainTextToolCallBlocks( + 'commentary to=write code {"path":"/tmp/file.txt","content":"x"}', + { allowedToolNames: ["read"] }, + ); + + expect(blocks).toBeNull(); + }); +}); + +describe("stripPlainTextToolCallBlocks", () => { + it("strips standalone Harmony tool calls", () => { + expect( + stripPlainTextToolCallBlocks( + 'before\ncommentary to=read code {"path":"/tmp/file.txt"}\nafter', + ), + ).toBe("before\nafter"); + }); +}); diff --git a/src/plugin-sdk/tool-payload.ts b/src/plugin-sdk/tool-payload.ts index 417b1244f81..d9a5fd5c533 100644 --- a/src/plugin-sdk/tool-payload.ts +++ b/src/plugin-sdk/tool-payload.ts @@ -57,6 +57,15 @@ export type PlainTextToolCallParseOptions = { const DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES = 256_000; const END_TOOL_REQUEST = "[END_TOOL_REQUEST]"; +const HARMONY_CHANNEL_MARKER = "<|channel|>"; +const HARMONY_MESSAGE_MARKER = "<|message|>"; +const HARMONY_CALL_MARKER = "<|call|>"; + +type PlainTextToolCallOpening = { + end: number; + name: string; + requiresClosing: boolean; +}; function isToolNameChar(char: string | undefined): boolean { return Boolean(char && /[A-Za-z0-9_-]/.test(char)); @@ -88,7 +97,7 @@ function consumeLineBreak(text: string, start: number): number | null { return null; } -function parseOpening(text: string, start: number): { end: number; name: string } | null { +function parseBracketOpening(text: string, start: number): PlainTextToolCallOpening | null { if (text[start] !== "[") { return null; } @@ -107,7 +116,49 @@ function parseOpening(text: string, start: number): { end: number; name: string if (afterLineBreak === null) { return null; } - return { end: afterLineBreak, name }; + return { end: afterLineBreak, name, requiresClosing: true }; +} + +function parseHarmonyOpening(text: string, start: number): PlainTextToolCallOpening | null { + let cursor = start; + if (text.startsWith(HARMONY_CHANNEL_MARKER, cursor)) { + cursor += HARMONY_CHANNEL_MARKER.length; + } + const channelStart = cursor; + while (/[A-Za-z_]/.test(text[cursor] ?? "")) { + cursor += 1; + } + const channel = text.slice(channelStart, cursor); + if (channel !== "commentary" && channel !== "analysis" && channel !== "final") { + return null; + } + cursor = skipHorizontalWhitespace(text, cursor); + if (!text.startsWith("to=", cursor)) { + return null; + } + cursor += 3; + const nameStart = cursor; + while (isToolNameChar(text[cursor])) { + cursor += 1; + } + if (cursor === nameStart) { + return null; + } + const name = text.slice(nameStart, cursor); + cursor = skipHorizontalWhitespace(text, cursor); + if (!text.startsWith("code", cursor)) { + return null; + } + cursor += 4; + cursor = skipWhitespace(text, cursor); + if (text.startsWith(HARMONY_MESSAGE_MARKER, cursor)) { + cursor = skipWhitespace(text, cursor + HARMONY_MESSAGE_MARKER.length); + } + return { end: cursor, name, requiresClosing: false }; +} + +function parseOpening(text: string, start: number): PlainTextToolCallOpening | null { + return parseBracketOpening(text, start) ?? parseHarmonyOpening(text, start); } function consumeJsonObject( @@ -174,6 +225,14 @@ function parseClosing(text: string, start: number, name: string): number | null return null; } +function parseOptionalHarmonyClosing(text: string, start: number): number { + const cursor = skipWhitespace(text, start); + if (text.startsWith(HARMONY_CALL_MARKER, cursor)) { + return cursor + HARMONY_CALL_MARKER.length; + } + return start; +} + function parsePlainTextToolCallBlockAt( text: string, start: number, @@ -197,15 +256,17 @@ function parsePlainTextToolCallBlockAt( if (!payload) { return null; } - const end = parseClosing(text, payload.end, opening.name); - if (end === null) { + const closingEnd = opening.requiresClosing + ? parseClosing(text, payload.end, opening.name) + : parseOptionalHarmonyClosing(text, payload.end); + if (closingEnd === null) { return null; } return { arguments: payload.value, - end, + end: closingEnd, name: opening.name, - raw: text.slice(start, end), + raw: text.slice(start, closingEnd), start, }; } @@ -228,7 +289,11 @@ export function parseStandalonePlainTextToolCallBlocks( } export function stripPlainTextToolCallBlocks(text: string): string { - if (!text || !/\[[A-Za-z0-9_-]+\]/.test(text)) { + if ( + !text || + (!/\[[A-Za-z0-9_-]+\]/.test(text) && + !/(?:^|\n)\s*(?:<\|channel\|>)?(?:commentary|analysis|final)\s+to=/.test(text)) + ) { return text; } let result = ""; @@ -248,7 +313,11 @@ export function stripPlainTextToolCallBlocks(text: string): string { } result += text.slice(cursor, index); cursor = block.end; - index = block.end; + const afterBlockLineBreak = consumeLineBreak(text, cursor); + if (afterBlockLineBreak !== null) { + cursor = afterBlockLineBreak; + } + index = cursor; } result += text.slice(cursor); return result;