fix(plugin-sdk): parse harmony text tool calls

This commit is contained in:
Peter Steinberger
2026-05-09 09:18:19 +01:00
parent 089d777413
commit ba2b033774
5 changed files with 195 additions and 10 deletions

View File

@@ -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.

View File

@@ -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<Record<string, unknown>>; 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]",

View File

@@ -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: {

View File

@@ -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");
});
});

View File

@@ -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;