Skip to content

Commit 6baa3df

Browse files
QVAC-19908 fix: recover Qwen hybrid tool-call frames (tetherto#2677)
* fix: recover Qwen hybrid tool-call frames (QVAC-19908) * QVAC-19908 doc: explain Qwen hybrid tool-call frame origin Document why repairFunctionEqualsJson exists: Qwen3.5/3.6 can fuse its XML and JSON tool templates into a single `{"function=NAME","arguments":{...}}` frame, and the repair is intentionally scoped to that exact shape so well-formed JSON frames are never rewritten. (cherry picked from commit 4b3ac99)
1 parent 9a46100 commit 6baa3df

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

packages/sdk/server/utils/tools/parsers/hermes.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ import {
66
type ParserResult,
77
} from "@/server/utils/tools/shared";
88

9+
// Qwen3.5/3.6 can fuse its two tool templates into one frame, embedding the
10+
// XML `<function=NAME>` token as a bare string key inside the JSON envelope:
11+
// {"function=NAME","arguments":{...}} (invalid JSON, not parseable as-is)
12+
// Rewrite only that exact shape to a canonical `{"name":NAME,"arguments":...}`
13+
// frame. Kept deliberately narrow so well-formed JSON frames are never touched.
14+
function repairFunctionEqualsJson(candidate: string): string | undefined {
15+
const match =
16+
/^\{\s*"function=([^"]+)"\s*,\s*"arguments"\s*:\s*([\s\S]+)\}\s*$/.exec(
17+
candidate,
18+
);
19+
if (!match) return undefined;
20+
return `{"name":${JSON.stringify(match[1])},"arguments":${match[2]}}`;
21+
}
22+
23+
function parseHermesJson(candidate: string): unknown {
24+
try {
25+
return JSON.parse(candidate);
26+
} catch (err) {
27+
const repaired = repairFunctionEqualsJson(candidate);
28+
if (repaired === undefined) throw err;
29+
return JSON.parse(repaired);
30+
}
31+
}
32+
933
// Hermes-style: JSON payload wrapped in `<tool_call>...</tool_call>` tags
1034
export function parseHermesFormat(text: string, tools: Tool[]): ParserResult {
1135
const toolCalls: ToolCall[] = [];
@@ -32,7 +56,7 @@ export function parseHermesFormat(text: string, tools: Tool[]): ParserResult {
3256

3357
let callItem: unknown;
3458
try {
35-
callItem = JSON.parse(trimmedJson);
59+
callItem = parseHermesJson(trimmedJson);
3660
} catch (error) {
3761
errors.push({
3862
code: "PARSE_ERROR",
@@ -96,7 +120,7 @@ function recoverIncompleteHermesFrame(
96120

97121
let parsed: unknown;
98122
try {
99-
parsed = JSON.parse(candidate);
123+
parsed = parseHermesJson(candidate);
100124
} catch {
101125
return { matched: false, toolCalls: [], errors: [] };
102126
}

packages/sdk/test/unit/tool-parser.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,33 @@ test("parseToolCalls(dialect=qwen35): JSON inside tool_call falls through to her
972972
t.alike(toolCalls[0]?.arguments, { city: "Seoul" });
973973
});
974974

975+
test("parseToolCalls(dialect=qwen35): recovers function-equals JSON hybrid", (t) => {
976+
const webfetchTool: Tool = {
977+
type: "function",
978+
name: "webfetch",
979+
description: "Fetch a URL",
980+
parameters: {
981+
type: "object",
982+
properties: {
983+
url: { type: "string" },
984+
format: { type: "string" },
985+
},
986+
required: ["url"],
987+
},
988+
};
989+
const text = `<tool_call>
990+
{"function=webfetch","arguments":{"url":"https://docs.opencode.ai","format":"markdown"}}
991+
</tool_call>`;
992+
const { toolCalls, errors } = parseToolCalls(text, [webfetchTool], "qwen35");
993+
t.is(errors.length, 0);
994+
t.is(toolCalls.length, 1);
995+
t.is(toolCalls[0]?.name, "webfetch");
996+
t.alike(toolCalls[0]?.arguments, {
997+
url: "https://docs.opencode.ai",
998+
format: "markdown",
999+
});
1000+
});
1001+
9751002
// --- gemma4 structural and error-surface tests ---
9761003

9771004
test("parseGemma4NativeFormat: bare numeric arg is parsed as number", (t) => {

0 commit comments

Comments
 (0)