mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 06:10:44 +00:00
fix(plugin-sdk): parse harmony text tool calls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user