qwenproxy-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,2989 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { robustParseJSON } from "../utils/json.ts";
|
|
3
|
+
import { logger, isToolcallDebugEnabled } from "../core/logger.js";
|
|
4
|
+
import type { ParsedToolCall } from "./types";
|
|
5
|
+
import type { FunctionToolDefinition } from "./types";
|
|
6
|
+
import {
|
|
7
|
+
TOOL_CALL_OPEN,
|
|
8
|
+
TOOL_CALL_CLOSE,
|
|
9
|
+
getOpenNames,
|
|
10
|
+
getCloseNames,
|
|
11
|
+
matchToolCloseAt,
|
|
12
|
+
} from "./toolcall-tags.ts";
|
|
13
|
+
|
|
14
|
+
export interface ToolCallDelta {
|
|
15
|
+
index: number;
|
|
16
|
+
id?: string;
|
|
17
|
+
type?: "function";
|
|
18
|
+
function: {
|
|
19
|
+
name?: string;
|
|
20
|
+
arguments?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ParserResult {
|
|
25
|
+
text: string;
|
|
26
|
+
toolCalls: ParsedToolCall[];
|
|
27
|
+
toolCallDeltas: ToolCallDelta[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StreamingToolParserOptions {
|
|
31
|
+
incrementalToolCalls?: boolean;
|
|
32
|
+
/** Max tool calls per turn; 0 disables the cap. */
|
|
33
|
+
maxToolCallsPerTurn?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface IncrementalJsonToolSnapshot {
|
|
37
|
+
name: string | null;
|
|
38
|
+
argumentsValueStart: number | null;
|
|
39
|
+
argumentsValueEnd: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ActiveIncrementalToolCall {
|
|
43
|
+
index: number;
|
|
44
|
+
id: string;
|
|
45
|
+
name: string | null;
|
|
46
|
+
argumentsValueStart: number | null;
|
|
47
|
+
emittedArgumentsLength: number;
|
|
48
|
+
startEmitted: boolean;
|
|
49
|
+
disabled: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── XML Helpers ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const TOOL_END = "</" + "tool_call>";
|
|
55
|
+
|
|
56
|
+
interface ToolEndMatch {
|
|
57
|
+
index: number;
|
|
58
|
+
tag: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find a closing marker only when it is outside a JSON string. Tool arguments
|
|
63
|
+
* frequently contain source code or tests that mention the literal
|
|
64
|
+
* `</tool_call>`; using indexOf() would truncate those arguments early.
|
|
65
|
+
*
|
|
66
|
+
* Some Qwen-compatible templates emit the plural opening tag `<tool_calls>`
|
|
67
|
+
* while still using either singular or plural closing tags, so both forms are
|
|
68
|
+
* accepted here.
|
|
69
|
+
*/
|
|
70
|
+
function findToolEndOutsideJsonString(buffer: string): ToolEndMatch | null {
|
|
71
|
+
const lower = buffer.toLowerCase();
|
|
72
|
+
const main = scanCloseTagOutsideStringsAndFences(lower);
|
|
73
|
+
|
|
74
|
+
if (main && closeTagContentIsParseable(buffer, main.index)) return main;
|
|
75
|
+
|
|
76
|
+
// The escape-aware scan can exit a string early when the quote count is
|
|
77
|
+
// unbalanced (logs2 02:21:02 grep payload had 11 quotes), exposing a
|
|
78
|
+
// literal `</tool_call>` quoted in an argument value as a "real" close
|
|
79
|
+
// (logs 02:06:35 edit_file old_text contained such an example). The real
|
|
80
|
+
// close tag is terminal, so prefer the LAST parseable occurrence: a literal
|
|
81
|
+
// marker is always followed by more content, so its prefix never validates.
|
|
82
|
+
// Iterate from the end so a stray extra close tag (e.g. a duplicated
|
|
83
|
+
// `</tool_call>` after the last payload) does not shadow earlier valid
|
|
84
|
+
// payload boundaries — each candidate's prefix is checked for a parseable
|
|
85
|
+
// tool payload, and the first hit from the end wins (multiple consecutive
|
|
86
|
+
// missing-open payloads, each with their own close tag, are recovered one at
|
|
87
|
+
// a time by the caller's loop).
|
|
88
|
+
const occurrences = findCloseTagOccurrences(lower);
|
|
89
|
+
for (let i = occurrences.length - 1; i >= 0; i--) {
|
|
90
|
+
if (closeTagContentIsParseable(buffer, occurrences[i].index)) {
|
|
91
|
+
return occurrences[i];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No candidate holds a plausible payload: do NOT close here. Closing on an
|
|
96
|
+
// unparseable marker mid-stream truncates the payload at the literal tag
|
|
97
|
+
// (the 342-char log drop). Deferring lets the stream continue — when the
|
|
98
|
+
// real close tag arrives the scan succeeds, and at flush the unclosed-tool
|
|
99
|
+
// recovery chain (robustParseJSON, brace matching) handles the remainder.
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* First pass: escape-aware scan for a closing marker outside JSON strings and
|
|
105
|
+
* code-fence spans. In JSON a backslash always consumes the next character
|
|
106
|
+
* (\" -> quote, \\ -> backslash, \\\" -> backslash+quote), so the escaped
|
|
107
|
+
* char is skipped unconditionally. This keeps the scanner inside a string
|
|
108
|
+
* across valid escapes; it only goes wrong on UNBALANCED quote counts, which
|
|
109
|
+
* the two-tier selection in findToolEndOutsideJsonString covers.
|
|
110
|
+
*/
|
|
111
|
+
function scanCloseTagOutsideStringsAndFences(lower: string): ToolEndMatch | null {
|
|
112
|
+
let inString = false;
|
|
113
|
+
// Track inline code fences (backticks) as literal text, not real close
|
|
114
|
+
let codeFenceLength = 0;
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < lower.length; i++) {
|
|
117
|
+
const ch = lower[i];
|
|
118
|
+
if (inString) {
|
|
119
|
+
if (ch === "\\") {
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === '"') {
|
|
124
|
+
inString = false;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (ch === '"') {
|
|
130
|
+
inString = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (ch === "`") {
|
|
135
|
+
let runLength = 1;
|
|
136
|
+
while (i + runLength < lower.length && lower[i + runLength] === "`") {
|
|
137
|
+
runLength++;
|
|
138
|
+
}
|
|
139
|
+
if (codeFenceLength === 0) {
|
|
140
|
+
codeFenceLength = runLength;
|
|
141
|
+
} else if (runLength >= codeFenceLength) {
|
|
142
|
+
codeFenceLength = 0;
|
|
143
|
+
}
|
|
144
|
+
i += runLength - 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (codeFenceLength > 0) continue;
|
|
149
|
+
|
|
150
|
+
// Whitespace-tolerant close over every accepted tag (custom + legacy).
|
|
151
|
+
const closeLen = matchToolCloseAt(lower, i);
|
|
152
|
+
if (closeLen !== null) {
|
|
153
|
+
return { index: i, tag: lower.substring(i, i + closeLen) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** All occurrences of any accepted closing marker, in ascending index order. */
|
|
161
|
+
function findCloseTagOccurrences(lower: string): ToolEndMatch[] {
|
|
162
|
+
const occurrences: ToolEndMatch[] = [];
|
|
163
|
+
for (const name of getCloseNames()) {
|
|
164
|
+
const tag = `</${name.toLowerCase()}>`;
|
|
165
|
+
let from = 0;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const index = lower.indexOf(tag, from);
|
|
168
|
+
if (index === -1) break;
|
|
169
|
+
occurrences.push({ index, tag });
|
|
170
|
+
from = index + tag.length;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return occurrences.sort((a, b) => a.index - b.index);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Strict, deterministic check that the content before a candidate close tag
|
|
178
|
+
* is (or is trivially repairable to) a valid JSON tool-call payload. Used to
|
|
179
|
+
* decide whether a close-tag candidate is the REAL closing tag instead of a
|
|
180
|
+
* literal marker that unbalanced quotes exposed to the string scanner.
|
|
181
|
+
*
|
|
182
|
+
* robustParseJSON is deliberately NOT used here: it over-recovers (balances
|
|
183
|
+
* unclosed strings) and can throw on some inputs, so it would accept
|
|
184
|
+
* truncated content as a valid close position and re-introduce the early
|
|
185
|
+
* truncation bug.
|
|
186
|
+
*/
|
|
187
|
+
function closeTagContentIsParseable(buffer: string, endIdx: number): boolean {
|
|
188
|
+
const content = buffer.substring(0, endIdx).trim();
|
|
189
|
+
if (!content) return true;
|
|
190
|
+
return tryParseJsonToolPayload(content);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Plain-JSON.parse based candidate checks, in increasing tolerance order:
|
|
195
|
+
* raw payload -> narrow typo repairs -> doubled trailing brace/bracket ->
|
|
196
|
+
* missing opening brace/quote. Truncated payloads (unclosed strings) never
|
|
197
|
+
* pass, so a mid-string literal marker is not mistaken for a real close tag.
|
|
198
|
+
*/
|
|
199
|
+
function tryParseJsonToolPayload(content: string): boolean {
|
|
200
|
+
const tryParse = (s: string): boolean => {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(s);
|
|
203
|
+
return typeof parsed === "object" && parsed !== null;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (tryParse(content)) return true;
|
|
210
|
+
|
|
211
|
+
const repaired = repairCommonMalformedToolJson(content);
|
|
212
|
+
const stripped = content.replace(/\}+$/, "").replace(/\]+$/, "");
|
|
213
|
+
const strippedRepaired = repaired.replace(/\}+$/, "").replace(/\]+$/, "");
|
|
214
|
+
|
|
215
|
+
const candidates = [repaired, stripped];
|
|
216
|
+
if (repaired !== content) candidates.push(strippedRepaired);
|
|
217
|
+
candidates.push(`{\"${content}`, `{${content}`);
|
|
218
|
+
if (repaired !== content) candidates.push(`{\"${repaired}`, `{${repaired}`);
|
|
219
|
+
|
|
220
|
+
return candidates.some((candidate) => tryParse(candidate));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeToolNameForMatch(name: string): string {
|
|
224
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseJsonishString(value: string): unknown {
|
|
228
|
+
const trimmed = value.trim();
|
|
229
|
+
const candidates = [trimmed];
|
|
230
|
+
|
|
231
|
+
if (trimmed.includes('\\"')) {
|
|
232
|
+
candidates.push(trimmed.replace(/\\"/g, '"'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (trimmed.includes("\\\\")) {
|
|
236
|
+
candidates.push(trimmed.replace(/\\\\/g, "\\"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const candidate of candidates) {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(candidate);
|
|
242
|
+
} catch {}
|
|
243
|
+
|
|
244
|
+
if (candidate.startsWith("{")) {
|
|
245
|
+
try {
|
|
246
|
+
return robustParseJSON(candidate);
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function advanceMarkdownCodeState(
|
|
255
|
+
text: string,
|
|
256
|
+
initialDelimiterLength = 0,
|
|
257
|
+
): number {
|
|
258
|
+
let delimiterLength = initialDelimiterLength;
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < text.length;) {
|
|
261
|
+
if (text[i] !== "`") {
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let runLength = 1;
|
|
267
|
+
while (i + runLength < text.length && text[i + runLength] === "`") {
|
|
268
|
+
runLength++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (delimiterLength === 0) {
|
|
272
|
+
delimiterLength = runLength;
|
|
273
|
+
} else if (runLength >= delimiterLength) {
|
|
274
|
+
delimiterLength = 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
i += runLength;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return delimiterLength;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function findNextToolOpenTagOutsideMarkdownCode(
|
|
284
|
+
buffer: string,
|
|
285
|
+
initialDelimiterLength = 0,
|
|
286
|
+
): { index: number; openTag: string } | null {
|
|
287
|
+
let delimiterLength = initialDelimiterLength;
|
|
288
|
+
|
|
289
|
+
for (let i = 0; i < buffer.length;) {
|
|
290
|
+
if (buffer[i] === "`") {
|
|
291
|
+
let runLength = 1;
|
|
292
|
+
while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
|
|
293
|
+
runLength++;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (delimiterLength === 0) {
|
|
297
|
+
delimiterLength = runLength;
|
|
298
|
+
} else if (runLength >= delimiterLength) {
|
|
299
|
+
delimiterLength = 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
i += runLength;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (delimiterLength === 0 && buffer[i] === "<") {
|
|
307
|
+
const sub = buffer.substring(i);
|
|
308
|
+
for (const name of getOpenNames()) {
|
|
309
|
+
const match = sub.match(new RegExp(`^<${name}\\b[^>]*>`, "i"));
|
|
310
|
+
if (match && !isPrecededByBacktick(buffer, i)) {
|
|
311
|
+
return { index: i, openTag: match[0] };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
i++;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function findPartialToolOpenIndexOutsideMarkdownCode(
|
|
323
|
+
buffer: string,
|
|
324
|
+
initialDelimiterLength = 0,
|
|
325
|
+
): number {
|
|
326
|
+
let delimiterLength = initialDelimiterLength;
|
|
327
|
+
const openNames = getOpenNames();
|
|
328
|
+
|
|
329
|
+
for (let i = 0; i < buffer.length;) {
|
|
330
|
+
if (buffer[i] === "`") {
|
|
331
|
+
let runLength = 1;
|
|
332
|
+
while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
|
|
333
|
+
runLength++;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (delimiterLength === 0) {
|
|
337
|
+
delimiterLength = runLength;
|
|
338
|
+
} else if (runLength >= delimiterLength) {
|
|
339
|
+
delimiterLength = 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
i += runLength;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (delimiterLength === 0 && buffer[i] === "<") {
|
|
347
|
+
const tailLower = buffer.substring(i).toLowerCase();
|
|
348
|
+
if (!tailLower.includes(">")) {
|
|
349
|
+
for (const name of openNames) {
|
|
350
|
+
const full = `<${name.toLowerCase()}`;
|
|
351
|
+
if (full.startsWith(tailLower)) {
|
|
352
|
+
return i;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
i++;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return -1;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function looksLikeToolCallPayload(candidate: string): boolean {
|
|
365
|
+
const trimmed = candidate.trim();
|
|
366
|
+
if (!trimmed) return false;
|
|
367
|
+
|
|
368
|
+
if (trimmed.startsWith("[")) {
|
|
369
|
+
return trimmed.includes('"name"') || trimmed.includes("<parameter");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (trimmed.startsWith("{")) {
|
|
373
|
+
return (
|
|
374
|
+
trimmed.includes('"name"') ||
|
|
375
|
+
trimmed.includes('"arguments"') ||
|
|
376
|
+
trimmed.includes('"tool_name"') ||
|
|
377
|
+
trimmed.includes('"tool"')
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return trimmed.includes("<parameter") || trimmed.includes("<name>");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function findCandidateStarts(buffer: string): number[] {
|
|
385
|
+
const starts: number[] = [];
|
|
386
|
+
|
|
387
|
+
const pushAllMatches = (needle: string) => {
|
|
388
|
+
const haystack = needle.startsWith("<") ? buffer.toLowerCase() : buffer;
|
|
389
|
+
const target = needle.startsWith("<") ? needle.toLowerCase() : needle;
|
|
390
|
+
let idx = haystack.indexOf(target);
|
|
391
|
+
while (idx !== -1) {
|
|
392
|
+
starts.push(idx);
|
|
393
|
+
idx = haystack.indexOf(target, idx + 1);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
pushAllMatches("{");
|
|
398
|
+
pushAllMatches("[");
|
|
399
|
+
pushAllMatches("<parameter");
|
|
400
|
+
pushAllMatches("<name>");
|
|
401
|
+
|
|
402
|
+
return starts.sort((a, b) => a - b);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function looksLikePartialToolCallPayload(candidate: string): boolean {
|
|
406
|
+
const trimmed = candidate.trim();
|
|
407
|
+
if (!trimmed) return false;
|
|
408
|
+
|
|
409
|
+
if (trimmed.startsWith("[")) {
|
|
410
|
+
return trimmed.includes('"name"') || trimmed.includes("<parameter");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (trimmed.startsWith("{")) {
|
|
414
|
+
return (
|
|
415
|
+
trimmed.includes('"name"') ||
|
|
416
|
+
trimmed.includes('name":') ||
|
|
417
|
+
trimmed.includes('"arguments"') ||
|
|
418
|
+
trimmed.includes('"tool_name"') ||
|
|
419
|
+
trimmed.includes('"tool"')
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return trimmed.includes("<parameter") || trimmed.includes("<name>");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isInsideMarkdownCodeAtIndex(
|
|
427
|
+
buffer: string,
|
|
428
|
+
index: number,
|
|
429
|
+
initialDelimiterLength = 0,
|
|
430
|
+
): boolean {
|
|
431
|
+
return (
|
|
432
|
+
advanceMarkdownCodeState(
|
|
433
|
+
buffer.substring(0, index),
|
|
434
|
+
initialDelimiterLength,
|
|
435
|
+
) !== 0
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* True when the character immediately before `index` (ignoring whitespace) is
|
|
441
|
+
* a backtick. Model prose frequently quotes tool-call syntax inline, e.g.
|
|
442
|
+
* ``Chunks arrive with `{"name": "write_file"...``` — the global markdown
|
|
443
|
+
* fence counter can drift on such text (odd runs of inline backticks), so we
|
|
444
|
+
* also reject candidates/local markers that are directly prefixed by a backtick
|
|
445
|
+
* as literal quoted text instead of a real tool-call boundary.
|
|
446
|
+
*/
|
|
447
|
+
function isPrecededByBacktick(buffer: string, index: number): boolean {
|
|
448
|
+
let i = index - 1;
|
|
449
|
+
while (i >= 0 && /\s/.test(buffer[i])) i--;
|
|
450
|
+
return i >= 0 && buffer[i] === "`";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function findPartialMissingOpenToolCallIndex(
|
|
454
|
+
buffer: string,
|
|
455
|
+
initialDelimiterLength = 0,
|
|
456
|
+
): number {
|
|
457
|
+
if (findToolEndOutsideJsonString(buffer)) return -1;
|
|
458
|
+
|
|
459
|
+
const candidateStarts = findCandidateStarts(buffer);
|
|
460
|
+
for (const candidateStart of candidateStarts) {
|
|
461
|
+
if (
|
|
462
|
+
isPrecededByBacktick(buffer, candidateStart) ||
|
|
463
|
+
isInsideMarkdownCodeAtIndex(
|
|
464
|
+
buffer,
|
|
465
|
+
candidateStart,
|
|
466
|
+
initialDelimiterLength,
|
|
467
|
+
)
|
|
468
|
+
) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const candidate = buffer.substring(candidateStart);
|
|
473
|
+
if (looksLikePartialToolCallPayload(candidate)) return candidateStart;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The buffer starts with an object/array but is too short to show a key
|
|
477
|
+
// yet (e.g. chunk boundary cut `{"na`). Hold it so later chunks can
|
|
478
|
+
// complete the missing-open payload — once tool calls were emitted,
|
|
479
|
+
// dropping it here would silently lose the call (user's multi-call pattern
|
|
480
|
+
// with `arguments"` unquoted keys). If it never completes, flush restores
|
|
481
|
+
// it as visible text (or discards it, identical to today's behavior when
|
|
482
|
+
// tool calls were already emitted).
|
|
483
|
+
const firstNonWs = buffer.search(/\S/);
|
|
484
|
+
if (
|
|
485
|
+
firstNonWs !== -1 &&
|
|
486
|
+
(buffer[firstNonWs] === "{" || buffer[firstNonWs] === "[")
|
|
487
|
+
) {
|
|
488
|
+
return firstNonWs;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return -1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function findRecoverableMissingOpenToolCall(
|
|
495
|
+
buffer: string,
|
|
496
|
+
initialDelimiterLength = 0,
|
|
497
|
+
): {
|
|
498
|
+
textBefore: string;
|
|
499
|
+
candidate: string;
|
|
500
|
+
consumeLength: number;
|
|
501
|
+
closeTag: string;
|
|
502
|
+
} | null {
|
|
503
|
+
const endMatch = findToolEndOutsideJsonString(buffer);
|
|
504
|
+
if (!endMatch) return null;
|
|
505
|
+
|
|
506
|
+
const endIdx = endMatch.index;
|
|
507
|
+
const beforeEnd = buffer.substring(0, endIdx);
|
|
508
|
+
const candidateStarts = findCandidateStarts(beforeEnd);
|
|
509
|
+
|
|
510
|
+
for (const candidateStart of candidateStarts) {
|
|
511
|
+
if (
|
|
512
|
+
isPrecededByBacktick(beforeEnd, candidateStart) ||
|
|
513
|
+
isInsideMarkdownCodeAtIndex(
|
|
514
|
+
beforeEnd,
|
|
515
|
+
candidateStart,
|
|
516
|
+
initialDelimiterLength,
|
|
517
|
+
) ||
|
|
518
|
+
isInsideMarkdownCodeAtIndex(buffer, endIdx, initialDelimiterLength)
|
|
519
|
+
) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const candidate = beforeEnd.substring(candidateStart).trim();
|
|
524
|
+
if (!looksLikeToolCallPayload(candidate)) continue;
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
textBefore: beforeEnd.substring(0, candidateStart),
|
|
528
|
+
candidate,
|
|
529
|
+
consumeLength: endIdx + endMatch.tag.length,
|
|
530
|
+
closeTag: endMatch.tag,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function decodeXmlEntities(value: string): string {
|
|
538
|
+
return value
|
|
539
|
+
.replace(/"/g, '"')
|
|
540
|
+
.replace(/'/g, "'")
|
|
541
|
+
.replace(/</g, "<")
|
|
542
|
+
.replace(/>/g, ">")
|
|
543
|
+
.replace(/&/g, "&");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function coerceParameterValue(rawValue: string): unknown {
|
|
547
|
+
const value = decodeXmlEntities(rawValue.trim());
|
|
548
|
+
if (value === "true") return true;
|
|
549
|
+
if (value === "false") return false;
|
|
550
|
+
if (value === "null") return null;
|
|
551
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
552
|
+
if (
|
|
553
|
+
(value.startsWith("{") && value.endsWith("}")) ||
|
|
554
|
+
(value.startsWith("[") && value.endsWith("]"))
|
|
555
|
+
) {
|
|
556
|
+
try {
|
|
557
|
+
return JSON.parse(value);
|
|
558
|
+
} catch {}
|
|
559
|
+
}
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Extract tool name from the opening tag attribute or a <name> child element.
|
|
565
|
+
*/
|
|
566
|
+
function extractToolName(openTag: string, block: string): string {
|
|
567
|
+
const combined = `${openTag}\n${block}`;
|
|
568
|
+
const attrMatch = combined.match(
|
|
569
|
+
/<tool_call(?:s)?\b[^>]*\bname\s*=\s*["']([^"']+)["']/i,
|
|
570
|
+
);
|
|
571
|
+
if (attrMatch) return attrMatch[1];
|
|
572
|
+
|
|
573
|
+
const nameTagMatch = block.match(/<name>([\s\S]*?)<\/name>/i);
|
|
574
|
+
if (nameTagMatch) return decodeXmlEntities(nameTagMatch[1].trim());
|
|
575
|
+
|
|
576
|
+
return "";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Infer tool name by matching parameter keys against tool definitions.
|
|
581
|
+
* Only returns a name if exactly one tool matches all argument keys.
|
|
582
|
+
*/
|
|
583
|
+
function inferToolNameFromParameters(
|
|
584
|
+
args: Record<string, unknown>,
|
|
585
|
+
tools: ToolDefinitionLike[],
|
|
586
|
+
): string {
|
|
587
|
+
const argKeys = Object.keys(args);
|
|
588
|
+
if (argKeys.length === 0 || !Array.isArray(tools)) return "";
|
|
589
|
+
|
|
590
|
+
const matches = tools.filter((tool) => {
|
|
591
|
+
const properties = getToolDefinitionProperties(tool);
|
|
592
|
+
return argKeys.every((k) =>
|
|
593
|
+
Object.prototype.hasOwnProperty.call(properties, k),
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (matches.length === 1) {
|
|
598
|
+
return getToolDefinitionName(matches[0]) || "";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return "";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Parse Hermes-style XML <parameter name="...">value</parameter> format.
|
|
606
|
+
*/
|
|
607
|
+
function parseXmlParameterToolCall(
|
|
608
|
+
block: string,
|
|
609
|
+
openTag: string,
|
|
610
|
+
tools: ToolDefinitionLike[],
|
|
611
|
+
): { name: string; arguments: Record<string, unknown> } | null {
|
|
612
|
+
const args: Record<string, unknown> = {};
|
|
613
|
+
const parameterRe =
|
|
614
|
+
/<parameter\b[^>]*\bname\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter>/gi;
|
|
615
|
+
let match: RegExpExecArray | null = parameterRe.exec(block);
|
|
616
|
+
while (match !== null) {
|
|
617
|
+
args[match[1]] = coerceParameterValue(match[2]);
|
|
618
|
+
match = parameterRe.exec(block);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (Object.keys(args).length === 0) return null;
|
|
622
|
+
|
|
623
|
+
const toolName =
|
|
624
|
+
extractToolName(openTag, block) || inferToolNameFromParameters(args, tools);
|
|
625
|
+
if (!toolName) return null;
|
|
626
|
+
|
|
627
|
+
return { name: toolName, arguments: args };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Try to recover a tool call from a block that may have unclosed <parameter> tags
|
|
632
|
+
* (e.g. stream was cut off before </parameter> or </tool_call>).
|
|
633
|
+
*/
|
|
634
|
+
function parseRecoverableXmlToolCall(
|
|
635
|
+
block: string,
|
|
636
|
+
openTag: string,
|
|
637
|
+
tools: ToolDefinitionLike[],
|
|
638
|
+
): { name: string; arguments: Record<string, unknown> } | null {
|
|
639
|
+
const args: Record<string, unknown> = {};
|
|
640
|
+
|
|
641
|
+
// First, extract all properly closed parameters
|
|
642
|
+
const closedParameterRe =
|
|
643
|
+
/<parameter\b[^>]*\bname\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter>/gi;
|
|
644
|
+
let match: RegExpExecArray | null = closedParameterRe.exec(block);
|
|
645
|
+
let lastClosedEnd = 0;
|
|
646
|
+
while (match !== null) {
|
|
647
|
+
args[match[1]] = coerceParameterValue(match[2]);
|
|
648
|
+
lastClosedEnd = closedParameterRe.lastIndex;
|
|
649
|
+
match = closedParameterRe.exec(block);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Then look for an unclosed parameter at the tail
|
|
653
|
+
const tail = block.substring(lastClosedEnd);
|
|
654
|
+
const unclosedMatch = tail.match(
|
|
655
|
+
/<parameter\b[^>]*\bname\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*)$/i,
|
|
656
|
+
);
|
|
657
|
+
if (unclosedMatch) {
|
|
658
|
+
args[unclosedMatch[1]] = coerceParameterValue(unclosedMatch[2]);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (Object.keys(args).length === 0) return null;
|
|
662
|
+
|
|
663
|
+
const toolName =
|
|
664
|
+
extractToolName(openTag, block) || inferToolNameFromParameters(args, tools);
|
|
665
|
+
if (!toolName) return null;
|
|
666
|
+
|
|
667
|
+
return { name: toolName, arguments: args };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ─── Partial Tag Detection ─────────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
const TOOL_START_LITERAL = TOOL_CALL_OPEN;
|
|
673
|
+
|
|
674
|
+
function skipJsonWhitespace(str: string, index: number): number {
|
|
675
|
+
while (index < str.length && /\s/.test(str[index])) {
|
|
676
|
+
index++;
|
|
677
|
+
}
|
|
678
|
+
return index;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function scanJsonStringEnd(
|
|
682
|
+
str: string,
|
|
683
|
+
start: number,
|
|
684
|
+
): { complete: boolean; end: number } {
|
|
685
|
+
if (str[start] !== '"') {
|
|
686
|
+
return { complete: false, end: start };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let escaped = false;
|
|
690
|
+
for (let i = start + 1; i < str.length; i++) {
|
|
691
|
+
const ch = str[i];
|
|
692
|
+
if (escaped) {
|
|
693
|
+
escaped = false;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (ch === "\\") {
|
|
697
|
+
escaped = true;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (ch === '"') {
|
|
701
|
+
return { complete: true, end: i + 1 };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return { complete: false, end: str.length };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function scanJsonCompositeValueEnd(
|
|
709
|
+
str: string,
|
|
710
|
+
start: number,
|
|
711
|
+
): { complete: boolean; end: number } {
|
|
712
|
+
const stack: string[] = [str[start]];
|
|
713
|
+
let inString = false;
|
|
714
|
+
let escaped = false;
|
|
715
|
+
|
|
716
|
+
for (let i = start + 1; i < str.length; i++) {
|
|
717
|
+
const ch = str[i];
|
|
718
|
+
|
|
719
|
+
if (inString) {
|
|
720
|
+
if (escaped) {
|
|
721
|
+
escaped = false;
|
|
722
|
+
} else if (ch === "\\") {
|
|
723
|
+
escaped = true;
|
|
724
|
+
} else if (ch === '"') {
|
|
725
|
+
inString = false;
|
|
726
|
+
}
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (ch === '"') {
|
|
731
|
+
inString = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (ch === "{" || ch === "[") {
|
|
736
|
+
stack.push(ch);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (ch === "}" || ch === "]") {
|
|
741
|
+
const last = stack[stack.length - 1];
|
|
742
|
+
if ((last === "{" && ch === "}") || (last === "[" && ch === "]")) {
|
|
743
|
+
stack.pop();
|
|
744
|
+
if (stack.length === 0) {
|
|
745
|
+
return { complete: true, end: i + 1 };
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
return { complete: false, end: str.length };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { complete: false, end: str.length };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function isJsonPrimitiveComplete(token: string): boolean {
|
|
757
|
+
if (!token) return false;
|
|
758
|
+
if (token === "true" || token === "false" || token === "null") {
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function scanJsonPrimitiveValueEnd(
|
|
765
|
+
str: string,
|
|
766
|
+
start: number,
|
|
767
|
+
): { complete: boolean; end: number } {
|
|
768
|
+
let i = start;
|
|
769
|
+
while (i < str.length && !/[\s,}\]]/.test(str[i])) {
|
|
770
|
+
i++;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const token = str.substring(start, i);
|
|
774
|
+
if (i === str.length) {
|
|
775
|
+
return { complete: isJsonPrimitiveComplete(token), end: i };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return { complete: isJsonPrimitiveComplete(token), end: i };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function scanJsonValueEnd(
|
|
782
|
+
str: string,
|
|
783
|
+
start: number,
|
|
784
|
+
): { complete: boolean; end: number } | null {
|
|
785
|
+
const valueStart = skipJsonWhitespace(str, start);
|
|
786
|
+
if (valueStart >= str.length) return null;
|
|
787
|
+
|
|
788
|
+
const ch = str[valueStart];
|
|
789
|
+
if (ch === '"') {
|
|
790
|
+
return scanJsonStringEnd(str, valueStart);
|
|
791
|
+
}
|
|
792
|
+
if (ch === "{" || ch === "[") {
|
|
793
|
+
return scanJsonCompositeValueEnd(str, valueStart);
|
|
794
|
+
}
|
|
795
|
+
return scanJsonPrimitiveValueEnd(str, valueStart);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function inspectIncrementalJsonToolObject(
|
|
799
|
+
content: string,
|
|
800
|
+
): IncrementalJsonToolSnapshot | null {
|
|
801
|
+
let pos = skipJsonWhitespace(content, 0);
|
|
802
|
+
if (pos >= content.length || content[pos] !== "{") {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const snapshot: IncrementalJsonToolSnapshot = {
|
|
807
|
+
name: null,
|
|
808
|
+
argumentsValueStart: null,
|
|
809
|
+
argumentsValueEnd: null,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
pos++;
|
|
813
|
+
|
|
814
|
+
while (pos < content.length) {
|
|
815
|
+
pos = skipJsonWhitespace(content, pos);
|
|
816
|
+
if (pos >= content.length) return snapshot;
|
|
817
|
+
|
|
818
|
+
if (content[pos] === ",") {
|
|
819
|
+
pos++;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (content[pos] === "}") {
|
|
824
|
+
return snapshot;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (content[pos] !== '"') {
|
|
828
|
+
return snapshot;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const keyScan = scanJsonStringEnd(content, pos);
|
|
832
|
+
if (!keyScan.complete) return snapshot;
|
|
833
|
+
|
|
834
|
+
let key = "";
|
|
835
|
+
try {
|
|
836
|
+
key = JSON.parse(content.substring(pos, keyScan.end));
|
|
837
|
+
} catch {
|
|
838
|
+
return snapshot;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
pos = skipJsonWhitespace(content, keyScan.end);
|
|
842
|
+
if (pos >= content.length || content[pos] !== ":") {
|
|
843
|
+
return snapshot;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
pos = skipJsonWhitespace(content, pos + 1);
|
|
847
|
+
if (pos >= content.length) return snapshot;
|
|
848
|
+
|
|
849
|
+
const valueStart = pos;
|
|
850
|
+
|
|
851
|
+
if (key === "name" && content[valueStart] === '"') {
|
|
852
|
+
const valueScan = scanJsonStringEnd(content, valueStart);
|
|
853
|
+
if (!valueScan.complete) return snapshot;
|
|
854
|
+
try {
|
|
855
|
+
const parsedName = JSON.parse(
|
|
856
|
+
content.substring(valueStart, valueScan.end),
|
|
857
|
+
);
|
|
858
|
+
if (typeof parsedName === "string") {
|
|
859
|
+
snapshot.name = parsedName;
|
|
860
|
+
}
|
|
861
|
+
} catch {}
|
|
862
|
+
pos = valueScan.end;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const valueScan = scanJsonValueEnd(content, valueStart);
|
|
867
|
+
if (key === "arguments") {
|
|
868
|
+
snapshot.argumentsValueStart = valueStart;
|
|
869
|
+
if (valueScan?.complete) {
|
|
870
|
+
snapshot.argumentsValueEnd = valueScan.end;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (!valueScan || !valueScan.complete) {
|
|
875
|
+
return snapshot;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
pos = valueScan.end;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return snapshot;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Repair narrow typos observed in Qwen tool-call output:
|
|
886
|
+
* - `"arguments>{...}` should be `"arguments": {...}`
|
|
887
|
+
* - `,arguments":{...}` should be `,"arguments":{...}` (missing opening quote)
|
|
888
|
+
* - a string value whose OPENING quote was dropped: `"key": value..."`
|
|
889
|
+
* - an array whose closing `]` was dropped: `[elem, "key": ...`
|
|
890
|
+
* Do not apply broad JSON mutation here because tool arguments can legitimately
|
|
891
|
+
* contain arbitrary text.
|
|
892
|
+
*/
|
|
893
|
+
function repairCommonMalformedToolJson(content: string): string {
|
|
894
|
+
const repaired = content
|
|
895
|
+
.replace(
|
|
896
|
+
/([,{]\s*)"arguments\s*>\s*(?={|\[|")/g,
|
|
897
|
+
'$1"arguments": ',
|
|
898
|
+
)
|
|
899
|
+
.replace(
|
|
900
|
+
/([,{]\s*)arguments"\s*:/g,
|
|
901
|
+
'$1"arguments":',
|
|
902
|
+
)
|
|
903
|
+
.replace(
|
|
904
|
+
/([,{]\s*)arguments\s*:\s*(?={|\[|")/g,
|
|
905
|
+
'$1"arguments":',
|
|
906
|
+
)
|
|
907
|
+
.replace(
|
|
908
|
+
/([,{]\s*)arguments\s*>\s*(?={|\[|")/g,
|
|
909
|
+
'$1"arguments":',
|
|
910
|
+
)
|
|
911
|
+
.replace(
|
|
912
|
+
/([,{]\s*)"arguments"\s*:\s*([A-Za-z_][A-Za-z0-9_]*)"\s*:/g,
|
|
913
|
+
'$1"arguments":{"$2":',
|
|
914
|
+
)
|
|
915
|
+
.replace(
|
|
916
|
+
// Missing OPENING quote of a string value: `"key": word...` — the model
|
|
917
|
+
// dropped the opening quote but kept the closing one. Valid JSON never has
|
|
918
|
+
// a bare-word value, so inserting the quote is safe (true/false/null and
|
|
919
|
+
// numbers are excluded). The trailing `\"` escapes are preserved, so the
|
|
920
|
+
// model's closing quote still terminates the string.
|
|
921
|
+
/([,{]\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*)(?=(?!true|false|null)[A-Za-z_])/g,
|
|
922
|
+
'$1"',
|
|
923
|
+
);
|
|
924
|
+
return repairMissingArrayClose(repaired);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Close an array whose closing `]` was dropped: `[elem, "key": value` — the
|
|
929
|
+
* model wrote the next key as a sibling of the last array element. A
|
|
930
|
+
* string-key (`"key":`) inside an array is never valid JSON, so when the
|
|
931
|
+
* bracket stack is inside an unclosed `[` and a string closes directly into
|
|
932
|
+
* `:`, the array is closed before that key. String content (with escapes) is
|
|
933
|
+
* never treated as structure.
|
|
934
|
+
*/
|
|
935
|
+
/**
|
|
936
|
+
* Raw structural scan: true when the content ends INSIDE a string or with
|
|
937
|
+
* unclosed brackets.
|
|
938
|
+
*/
|
|
939
|
+
function scanJsonStructureIncomplete(content: string): boolean {
|
|
940
|
+
let inString = false;
|
|
941
|
+
let escaped = false;
|
|
942
|
+
let depth = 0;
|
|
943
|
+
for (let i = 0; i < content.length; i++) {
|
|
944
|
+
const ch = content[i];
|
|
945
|
+
if (inString) {
|
|
946
|
+
if (escaped) {
|
|
947
|
+
escaped = false;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (ch === "\\") {
|
|
951
|
+
escaped = true;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (ch === '"') inString = false;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (ch === '"') {
|
|
958
|
+
inString = true;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (ch === "{" || ch === "[") depth++;
|
|
962
|
+
else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
|
|
963
|
+
}
|
|
964
|
+
return inString || depth > 0;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* True when a JSON payload ends structurally INCOMPLETE — inside a string or
|
|
969
|
+
* with unclosed brackets. Such payloads are TRUNCATED (stream cut, premature
|
|
970
|
+
* close) and must be dropped + auto-retried rather than "recovered": the
|
|
971
|
+
* recovery parsers (robustParseJSON) balance unclosed strings, which would
|
|
972
|
+
* silently accept the truncation and stream a broken tool call to the client
|
|
973
|
+
* while skipping the malformed auto-retry (logs1 2829-char write drop).
|
|
974
|
+
*
|
|
975
|
+
* The raw scan misreads REPAIRABLE payloads as truncated, so it is retried on
|
|
976
|
+
* alternatives before declaring truncation:
|
|
977
|
+
* - missing opening `{`/quote changes the quote parity (`name": ...}}`)
|
|
978
|
+
* - escaped quotes in surrounding junk text misalign string state
|
|
979
|
+
* - double-encoded JSON (`\"` on every quote)
|
|
980
|
+
*/
|
|
981
|
+
function isJsonPayloadTruncated(content: string): boolean {
|
|
982
|
+
if (!scanJsonStructureIncomplete(content)) return false;
|
|
983
|
+
// A genuinely truncated payload ends MID-VALUE: the model was cut while
|
|
984
|
+
// emitting a string or before a closing bracket, so the last non-space
|
|
985
|
+
// character is text, a quote, or a comma — NOT a closing bracket. The
|
|
986
|
+
// prefix-candidates below only exist for typo-repairable payloads (missing
|
|
987
|
+
// leading `{`/quote like `name": ...}}`, unbalanced quotes, double
|
|
988
|
+
// encoding) whose STRUCTURE is complete: they end with a visible closing
|
|
989
|
+
// bracket. Without this gate, the `"`-prefix candidate flips quote parity
|
|
990
|
+
// and makes a mid-string cut (e.g. `..."old_text":"start`) scan as
|
|
991
|
+
// balanced, so robustParseJSON silently balances the string and streams a
|
|
992
|
+
// fabricated call (logs1 2829-char write drop).
|
|
993
|
+
const trimmed = content.trimEnd();
|
|
994
|
+
if (!/[}\]]$/.test(trimmed)) return true;
|
|
995
|
+
const alt: string[] = [];
|
|
996
|
+
if (content.includes('"name"') || content.includes('name":')) {
|
|
997
|
+
alt.push(`{"${content}`, `{${content}`, `"${content}`);
|
|
998
|
+
}
|
|
999
|
+
if (content.includes('\\"')) {
|
|
1000
|
+
alt.push(content.replace(/\\"/g, '"'));
|
|
1001
|
+
}
|
|
1002
|
+
for (const candidate of alt) {
|
|
1003
|
+
if (!scanJsonStructureIncomplete(candidate)) return false;
|
|
1004
|
+
}
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Strict JSON parse with ONLY the narrow repair chain — never robustParseJSON
|
|
1010
|
+
* (it balances unclosed strings and would accept a TRUNCATED arguments value
|
|
1011
|
+
* as valid). repairCommonMalformedToolJson only fixes missing-quote /
|
|
1012
|
+
* missing-array-close typos, which do not touch truncated strings.
|
|
1013
|
+
*/
|
|
1014
|
+
function parseToolArgumentsStrict(raw: string): unknown {
|
|
1015
|
+
try {
|
|
1016
|
+
return JSON.parse(raw);
|
|
1017
|
+
} catch {
|
|
1018
|
+
// fall through to the narrow repairs
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
return JSON.parse(repairCommonMalformedToolJson(raw));
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function repairMissingArrayClose(input: string): string {
|
|
1028
|
+
const stack: Array<"{" | "["> = [];
|
|
1029
|
+
let out = "";
|
|
1030
|
+
let inString = false;
|
|
1031
|
+
let pendingStart: number | null = null;
|
|
1032
|
+
let i = 0;
|
|
1033
|
+
while (i < input.length) {
|
|
1034
|
+
const ch = input[i];
|
|
1035
|
+
if (inString) {
|
|
1036
|
+
out += ch;
|
|
1037
|
+
if (ch === "\\") {
|
|
1038
|
+
out += input[i + 1] ?? "";
|
|
1039
|
+
i += 2;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (ch === '"') {
|
|
1043
|
+
inString = false;
|
|
1044
|
+
if (pendingStart !== null) {
|
|
1045
|
+
let j = i + 1;
|
|
1046
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
1047
|
+
if (input[j] === ":") {
|
|
1048
|
+
// This string is a KEY inside an array → close the array. Move the
|
|
1049
|
+
// separating comma after the inserted `]`: }, "key": → }], "key":
|
|
1050
|
+
const before = out.slice(0, pendingStart);
|
|
1051
|
+
const commaIdx = before.lastIndexOf(",");
|
|
1052
|
+
if (commaIdx !== -1) {
|
|
1053
|
+
out =
|
|
1054
|
+
before.slice(0, commaIdx) +
|
|
1055
|
+
"]" +
|
|
1056
|
+
before.slice(commaIdx) +
|
|
1057
|
+
out.slice(pendingStart);
|
|
1058
|
+
} else {
|
|
1059
|
+
out = before + "]" + out.slice(pendingStart);
|
|
1060
|
+
}
|
|
1061
|
+
stack.pop();
|
|
1062
|
+
}
|
|
1063
|
+
pendingStart = null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
i++;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
if (ch === '"') {
|
|
1070
|
+
inString = true;
|
|
1071
|
+
pendingStart = stack[stack.length - 1] === "[" ? out.length : null;
|
|
1072
|
+
out += ch;
|
|
1073
|
+
i++;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
if (ch === "{" || ch === "[") {
|
|
1077
|
+
stack.push(ch);
|
|
1078
|
+
out += ch;
|
|
1079
|
+
i++;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (ch === "}" || ch === "]") {
|
|
1083
|
+
const top = stack[stack.length - 1];
|
|
1084
|
+
if ((top === "{" && ch === "}") || (top === "[" && ch === "]")) {
|
|
1085
|
+
stack.pop();
|
|
1086
|
+
}
|
|
1087
|
+
out += ch;
|
|
1088
|
+
i++;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
out += ch;
|
|
1092
|
+
i++;
|
|
1093
|
+
}
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ─── StreamingToolParser ───────────────────────────────────────────────────────
|
|
1098
|
+
|
|
1099
|
+
type FlatToolDefinition = {
|
|
1100
|
+
type?: string;
|
|
1101
|
+
name?: string;
|
|
1102
|
+
description?: string;
|
|
1103
|
+
parameters?: { properties?: Record<string, unknown> };
|
|
1104
|
+
function?: {
|
|
1105
|
+
name?: string;
|
|
1106
|
+
description?: string;
|
|
1107
|
+
parameters?: { properties?: Record<string, unknown> };
|
|
1108
|
+
};
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
type ToolDefinitionLike = FunctionToolDefinition | FlatToolDefinition;
|
|
1112
|
+
|
|
1113
|
+
function getToolDefinitionName(tool: ToolDefinitionLike): string | undefined {
|
|
1114
|
+
if (tool.function?.name) return tool.function.name;
|
|
1115
|
+
if ("name" in tool && typeof tool.name === "string") return tool.name;
|
|
1116
|
+
return undefined;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function getToolDefinitionProperties(
|
|
1120
|
+
tool: ToolDefinitionLike | undefined,
|
|
1121
|
+
): Record<string, unknown> {
|
|
1122
|
+
if (!tool) return {};
|
|
1123
|
+
if (tool.function?.parameters?.properties) {
|
|
1124
|
+
return tool.function.parameters.properties;
|
|
1125
|
+
}
|
|
1126
|
+
if ("parameters" in tool && tool.parameters?.properties) {
|
|
1127
|
+
return tool.parameters.properties;
|
|
1128
|
+
}
|
|
1129
|
+
return {};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export class StreamingToolParser {
|
|
1133
|
+
private buffer = "";
|
|
1134
|
+
private insideTool = false;
|
|
1135
|
+
private currentOpenTag = TOOL_START_LITERAL;
|
|
1136
|
+
private currentCloseTag = TOOL_END;
|
|
1137
|
+
private emittedToolCallCount = 0;
|
|
1138
|
+
private pendingLeadIn = "";
|
|
1139
|
+
private tools: ToolDefinitionLike[] = [];
|
|
1140
|
+
private declaredToolNames: string[] = [];
|
|
1141
|
+
private declaredToolNameSet = new Set<string>();
|
|
1142
|
+
private toolByName = new Map<string, ToolDefinitionLike>();
|
|
1143
|
+
private normalizedDeclaredToolNames = new Map<string, string>();
|
|
1144
|
+
private markdownCodeDelimiterLength = 0;
|
|
1145
|
+
private incrementalToolCalls = false;
|
|
1146
|
+
private activeIncrementalToolCall: ActiveIncrementalToolCall | null = null;
|
|
1147
|
+
private maxToolCallsPerTurn = 0;
|
|
1148
|
+
private emittedCallKeys = new Set<string>();
|
|
1149
|
+
private pendingToolCallDeltas: ToolCallDelta[] = [];
|
|
1150
|
+
private malformedToolCalls: Array<{
|
|
1151
|
+
contentPreview: string;
|
|
1152
|
+
/** Full content (capped at 2000 chars) for post-hoc recovery analysis. */
|
|
1153
|
+
content: string;
|
|
1154
|
+
contentLength: number;
|
|
1155
|
+
timestamp: number;
|
|
1156
|
+
undeclaredNames?: string[];
|
|
1157
|
+
category: "malformed" | "undeclared" | "truncated";
|
|
1158
|
+
/** Human-readable reason the call could not be parsed/recovered. */
|
|
1159
|
+
failureReason?: string;
|
|
1160
|
+
/** Which recovery stages were attempted before giving up. */
|
|
1161
|
+
recoveryAttempts?: string[];
|
|
1162
|
+
}> = [];
|
|
1163
|
+
/** Valid tool calls dropped because the per-turn cap was reached. */
|
|
1164
|
+
private cappedToolCalls: Array<{ toolName: string; timestamp: number }> = [];
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* @param tools - Optional array of tool definitions for name inference
|
|
1168
|
+
*/
|
|
1169
|
+
constructor(
|
|
1170
|
+
tools: ToolDefinitionLike[] = [],
|
|
1171
|
+
options: StreamingToolParserOptions = {},
|
|
1172
|
+
) {
|
|
1173
|
+
this.setTools(tools);
|
|
1174
|
+
this.incrementalToolCalls = options.incrementalToolCalls ?? false;
|
|
1175
|
+
this.maxToolCallsPerTurn = Math.max(0, options.maxToolCallsPerTurn ?? 0);
|
|
1176
|
+
if (isToolcallDebugEnabled()) {
|
|
1177
|
+
logger.debug("[parser] StreamingToolParser initialized", {
|
|
1178
|
+
toolsCount: tools.length,
|
|
1179
|
+
toolNames: this.declaredToolNames,
|
|
1180
|
+
incrementalToolCalls: this.incrementalToolCalls,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Get malformed tool calls that were dropped (for error feedback).
|
|
1187
|
+
*/
|
|
1188
|
+
getMalformedToolCalls() {
|
|
1189
|
+
return this.malformedToolCalls;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Valid tool calls dropped because the per-turn cap was reached. These are
|
|
1194
|
+
* intentionally NOT retried: the turn already emitted calls up to the cap,
|
|
1195
|
+
* and a [SYSTEM CORRECTION] retry would amplify runaway generation.
|
|
1196
|
+
*/
|
|
1197
|
+
getCappedToolCalls() {
|
|
1198
|
+
return this.cappedToolCalls;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* True once the per-turn tool-call cap has been reached (the number of
|
|
1203
|
+
* processed tool calls hit `maxToolCallsPerTurn`). The streaming layer uses
|
|
1204
|
+
* this to stop consuming the upstream and close the turn cleanly
|
|
1205
|
+
* (finish_reason "tool_calls") instead of letting the model keep emitting
|
|
1206
|
+
* calls that would only be dropped. A cap-reached turn is a SUCCESSFUL turn
|
|
1207
|
+
* with valid calls, not an error — it must never trigger a mid-stream retry.
|
|
1208
|
+
*/
|
|
1209
|
+
isToolCapReached(): boolean {
|
|
1210
|
+
return (
|
|
1211
|
+
this.maxToolCallsPerTurn > 0 &&
|
|
1212
|
+
this.emittedToolCallCount >= this.maxToolCallsPerTurn
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Clear malformed tool calls tracking.
|
|
1218
|
+
*/
|
|
1219
|
+
clearMalformedToolCalls() {
|
|
1220
|
+
this.malformedToolCalls = [];
|
|
1221
|
+
this.cappedToolCalls = [];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Update the tools list (e.g. if received after construction).
|
|
1226
|
+
*/
|
|
1227
|
+
setTools(tools: ToolDefinitionLike[]): void {
|
|
1228
|
+
this.tools = tools;
|
|
1229
|
+
this.declaredToolNames = [];
|
|
1230
|
+
this.declaredToolNameSet = new Set<string>();
|
|
1231
|
+
this.toolByName = new Map<string, ToolDefinitionLike>();
|
|
1232
|
+
this.normalizedDeclaredToolNames = new Map<string, string>();
|
|
1233
|
+
|
|
1234
|
+
for (const tool of tools) {
|
|
1235
|
+
const name = this.getToolName(tool);
|
|
1236
|
+
if (!name) continue;
|
|
1237
|
+
this.declaredToolNames.push(name);
|
|
1238
|
+
this.declaredToolNameSet.add(name);
|
|
1239
|
+
this.toolByName.set(name, tool);
|
|
1240
|
+
const normalizedName = normalizeToolNameForMatch(name);
|
|
1241
|
+
if (!this.normalizedDeclaredToolNames.has(normalizedName)) {
|
|
1242
|
+
this.normalizedDeclaredToolNames.set(normalizedName, name);
|
|
1243
|
+
} else {
|
|
1244
|
+
this.normalizedDeclaredToolNames.set(normalizedName, "");
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private startIncrementalToolCall(): void {
|
|
1250
|
+
if (!this.incrementalToolCalls) return;
|
|
1251
|
+
this.activeIncrementalToolCall = {
|
|
1252
|
+
index: this.emittedToolCallCount,
|
|
1253
|
+
id: `call_${crypto.randomUUID()}`,
|
|
1254
|
+
name: null,
|
|
1255
|
+
argumentsValueStart: null,
|
|
1256
|
+
emittedArgumentsLength: 0,
|
|
1257
|
+
startEmitted: false,
|
|
1258
|
+
disabled: false,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private clearIncrementalToolCall(): void {
|
|
1263
|
+
this.activeIncrementalToolCall = null;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private getToolName(tool: ToolDefinitionLike): string | undefined {
|
|
1267
|
+
return getToolDefinitionName(tool);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private getToolProperties(
|
|
1271
|
+
tool: ToolDefinitionLike | undefined,
|
|
1272
|
+
): Record<string, unknown> {
|
|
1273
|
+
return getToolDefinitionProperties(tool);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private resolveDeclaredToolName(name: string): string | null {
|
|
1277
|
+
if (!name) return null;
|
|
1278
|
+
if (this.declaredToolNameSet.size === 0) return name;
|
|
1279
|
+
if (this.declaredToolNameSet.has(name)) return name;
|
|
1280
|
+
|
|
1281
|
+
const normalized = normalizeToolNameForMatch(name);
|
|
1282
|
+
const candidate = this.normalizedDeclaredToolNames.get(normalized);
|
|
1283
|
+
if (candidate) {
|
|
1284
|
+
logger.warn("[parser] Fuzzy-matched tool name to declared tool", {
|
|
1285
|
+
emittedToolName: name,
|
|
1286
|
+
matchedToolName: candidate,
|
|
1287
|
+
declaredTools: this.declaredToolNames,
|
|
1288
|
+
});
|
|
1289
|
+
return candidate;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private normalizeArgumentsForTool(
|
|
1296
|
+
name: string,
|
|
1297
|
+
args: Record<string, unknown>,
|
|
1298
|
+
): Record<string, unknown> {
|
|
1299
|
+
const toolProperties = this.getToolProperties(this.toolByName.get(name));
|
|
1300
|
+
let normalized = args;
|
|
1301
|
+
if (
|
|
1302
|
+
Object.keys(normalized).length === 1 &&
|
|
1303
|
+
Object.prototype.hasOwnProperty.call(normalized, "arguments") &&
|
|
1304
|
+
typeof (normalized as any).arguments === "object" &&
|
|
1305
|
+
(normalized as any).arguments !== null &&
|
|
1306
|
+
!Object.prototype.hasOwnProperty.call(toolProperties, "arguments")
|
|
1307
|
+
) {
|
|
1308
|
+
normalized = (normalized as any).arguments as Record<string, unknown>;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return this.coerceJsonLikeArgumentStrings(normalized);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
private coerceJsonLikeArgumentStrings(
|
|
1315
|
+
args: Record<string, unknown>,
|
|
1316
|
+
): Record<string, unknown> {
|
|
1317
|
+
const coerced: Record<string, unknown> = { ...args };
|
|
1318
|
+
|
|
1319
|
+
for (const [key, value] of Object.entries(coerced)) {
|
|
1320
|
+
if (typeof value !== "string") continue;
|
|
1321
|
+
|
|
1322
|
+
const trimmed = value.trim();
|
|
1323
|
+
if (!(
|
|
1324
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
1325
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
1326
|
+
)) {
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const parsed = parseJsonishString(trimmed);
|
|
1331
|
+
if (parsed !== undefined) {
|
|
1332
|
+
coerced[key] = parsed;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return coerced;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private toolCallDedupeKey(tc: ParsedToolCall): string {
|
|
1340
|
+
let canonicalArgs = "";
|
|
1341
|
+
try {
|
|
1342
|
+
canonicalArgs = JSON.stringify(tc.arguments);
|
|
1343
|
+
} catch {}
|
|
1344
|
+
return `${tc.name}::${canonicalArgs}`;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
private flushPendingToolCallDeltas(result: ParserResult): void {
|
|
1348
|
+
if (this.pendingToolCallDeltas.length > 0) {
|
|
1349
|
+
result.toolCallDeltas.push(...this.pendingToolCallDeltas);
|
|
1350
|
+
this.pendingToolCallDeltas = [];
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
private discardPendingToolCallDeltas(): void {
|
|
1355
|
+
this.pendingToolCallDeltas = [];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
private finalizeSuccessfulToolCall(
|
|
1359
|
+
tc: ParsedToolCall,
|
|
1360
|
+
result: ParserResult,
|
|
1361
|
+
): void {
|
|
1362
|
+
if (!this.isDeclaredToolName(tc.name)) {
|
|
1363
|
+
logger.warn("[parser] Undeclared tool call passed through", {
|
|
1364
|
+
toolName: tc.name,
|
|
1365
|
+
declaredTools: this.declaredToolNames,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const key = this.toolCallDedupeKey(tc);
|
|
1370
|
+
|
|
1371
|
+
// Qwen sometimes hallucinates the same tool call twice (or more) in one
|
|
1372
|
+
// turn, e.g. repeating edit_file with identical edits. The client would
|
|
1373
|
+
// execute the duplicates and burn quota/tokens; collapse them here.
|
|
1374
|
+
if (this.emittedCallKeys.has(key)) {
|
|
1375
|
+
if (isToolcallDebugEnabled()) {
|
|
1376
|
+
logger.debug("[parser] duplicate tool call suppressed (already emitted this turn)", {
|
|
1377
|
+
toolName: tc.name,
|
|
1378
|
+
argumentsHash: key,
|
|
1379
|
+
arguments: JSON.stringify(tc.arguments).substring(0, 500),
|
|
1380
|
+
emittedSoFar: this.emittedToolCallCount,
|
|
1381
|
+
note: "duplicate suppressed to prevent double-execution; no recovery needed",
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
this.discardPendingToolCallDeltas();
|
|
1385
|
+
this.pendingLeadIn = "";
|
|
1386
|
+
this.emittedToolCallCount++;
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Hard cap against runaway tool-call hallucination: the model is told to
|
|
1391
|
+
// emit only 1-4 blocks, but sometimes keeps generating calls without
|
|
1392
|
+
// stopping for tool results. Beyond the cap, extra calls are dropped so
|
|
1393
|
+
// the turn ends and the client can respond with the tool results.
|
|
1394
|
+
if (
|
|
1395
|
+
this.maxToolCallsPerTurn > 0 &&
|
|
1396
|
+
this.emittedToolCallCount >= this.maxToolCallsPerTurn
|
|
1397
|
+
) {
|
|
1398
|
+
// Track the drop explicitly (distinct from malformed calls): cap-drops
|
|
1399
|
+
// are VALID calls that were intentionally not emitted, so they must
|
|
1400
|
+
// never trigger a [SYSTEM CORRECTION] auto-retry (the turn already has
|
|
1401
|
+
// emitted calls). The stream summary surfaces them so runaway-tool
|
|
1402
|
+
// patterns are visible in the logs.
|
|
1403
|
+
this.cappedToolCalls.push({ toolName: tc.name, timestamp: Date.now() });
|
|
1404
|
+
logger.warn("[parser] Dropping tool call: per-turn cap reached", {
|
|
1405
|
+
toolName: tc.name,
|
|
1406
|
+
maxToolCallsPerTurn: this.maxToolCallsPerTurn,
|
|
1407
|
+
cappedCount: this.cappedToolCalls.length,
|
|
1408
|
+
});
|
|
1409
|
+
this.discardPendingToolCallDeltas();
|
|
1410
|
+
this.pendingLeadIn = "";
|
|
1411
|
+
this.emittedToolCallCount++;
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
this.emittedCallKeys.add(key);
|
|
1416
|
+
|
|
1417
|
+
const incremental = this.activeIncrementalToolCall;
|
|
1418
|
+
const matchesIncrementalCall =
|
|
1419
|
+
incremental?.name === tc.name && incremental.startEmitted;
|
|
1420
|
+
|
|
1421
|
+
if (incremental && incremental.name === tc.name) {
|
|
1422
|
+
tc.id = incremental.id;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (matchesIncrementalCall) {
|
|
1426
|
+
// The incremental deltas already carry this call's name/arguments; the
|
|
1427
|
+
// client merges them by index. Do NOT emit a complete call chunk again
|
|
1428
|
+
// or the arguments would be appended twice and corrupted.
|
|
1429
|
+
this.flushPendingToolCallDeltas(result);
|
|
1430
|
+
this.emittedToolCallCount++;
|
|
1431
|
+
this.pendingLeadIn = "";
|
|
1432
|
+
incremental.startEmitted = false;
|
|
1433
|
+
incremental.disabled = true;
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
this.flushPendingToolCallDeltas(result);
|
|
1438
|
+
result.toolCalls.push(tc);
|
|
1439
|
+
this.emittedToolCallCount++;
|
|
1440
|
+
this.pendingLeadIn = "";
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
private tryRecoverIncrementalToolCall(
|
|
1444
|
+
content: string,
|
|
1445
|
+
): ParsedToolCall | null {
|
|
1446
|
+
const incremental = this.activeIncrementalToolCall;
|
|
1447
|
+
if (
|
|
1448
|
+
!incremental ||
|
|
1449
|
+
!incremental.startEmitted ||
|
|
1450
|
+
!incremental.name ||
|
|
1451
|
+
incremental.argumentsValueStart === null
|
|
1452
|
+
) {
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const snapshot = inspectIncrementalJsonToolObject(content);
|
|
1457
|
+
const argsStart = incremental.argumentsValueStart;
|
|
1458
|
+
const argsEnd = snapshot?.argumentsValueEnd ?? content.length;
|
|
1459
|
+
const rawArgs = content.substring(argsStart, argsEnd).trim();
|
|
1460
|
+
if (!rawArgs) return null;
|
|
1461
|
+
|
|
1462
|
+
try {
|
|
1463
|
+
// Strict parse + narrow repairs only — never robustParseJSON, which
|
|
1464
|
+
// balances unclosed strings and would accept a TRUNCATED arguments
|
|
1465
|
+
// value as a complete call (silently streaming a broken call and
|
|
1466
|
+
// skipping the malformed auto-retry).
|
|
1467
|
+
const parsedArgs = parseToolArgumentsStrict(rawArgs);
|
|
1468
|
+
if (
|
|
1469
|
+
parsedArgs &&
|
|
1470
|
+
typeof parsedArgs === "object" &&
|
|
1471
|
+
!Array.isArray(parsedArgs)
|
|
1472
|
+
) {
|
|
1473
|
+
return {
|
|
1474
|
+
id: incremental.id,
|
|
1475
|
+
name: incremental.name,
|
|
1476
|
+
arguments: this.normalizeArgumentsForTool(
|
|
1477
|
+
incremental.name,
|
|
1478
|
+
parsedArgs as Record<string, unknown>,
|
|
1479
|
+
),
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
} catch {}
|
|
1483
|
+
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
private emitIncrementalToolCallDeltas(content: string): void {
|
|
1488
|
+
if (!this.incrementalToolCalls || !this.activeIncrementalToolCall) return;
|
|
1489
|
+
|
|
1490
|
+
const incremental = this.activeIncrementalToolCall;
|
|
1491
|
+
if (incremental.disabled) return;
|
|
1492
|
+
|
|
1493
|
+
// Once the per-turn cap is reached, no further incremental deltas may be
|
|
1494
|
+
// emitted. The over-cap call is finalized as a capped drop; streaming its
|
|
1495
|
+
// arguments would hand the client a partial tool call that is never
|
|
1496
|
+
// completed (and the turn is being closed early anyway).
|
|
1497
|
+
if (
|
|
1498
|
+
this.maxToolCallsPerTurn > 0 &&
|
|
1499
|
+
this.emittedToolCallCount >= this.maxToolCallsPerTurn
|
|
1500
|
+
) {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const snapshot = inspectIncrementalJsonToolObject(content);
|
|
1505
|
+
if (!snapshot) return;
|
|
1506
|
+
|
|
1507
|
+
if (snapshot.name && !incremental.name) {
|
|
1508
|
+
if (!this.isDeclaredToolName(snapshot.name)) {
|
|
1509
|
+
incremental.disabled = true;
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
// Store the RESOLVED name (fuzzy matching already verified the raw
|
|
1513
|
+
// name maps to a declared tool). Comparing the raw spelling against the
|
|
1514
|
+
// resolved name in finalizeSuccessfulToolCall would mismatch (e.g.
|
|
1515
|
+
// emitted `editFile` vs declared `edit_file`) and re-emit a complete
|
|
1516
|
+
// tool-call chunk on top of the streamed deltas — the duplicate the
|
|
1517
|
+
// client sees.
|
|
1518
|
+
incremental.name =
|
|
1519
|
+
this.resolveDeclaredToolName(snapshot.name) ?? snapshot.name;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (
|
|
1523
|
+
incremental.argumentsValueStart === null &&
|
|
1524
|
+
snapshot.argumentsValueStart !== null
|
|
1525
|
+
) {
|
|
1526
|
+
incremental.argumentsValueStart = snapshot.argumentsValueStart;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (!incremental.name) return;
|
|
1530
|
+
|
|
1531
|
+
const argsStart = incremental.argumentsValueStart;
|
|
1532
|
+
const argsEnd =
|
|
1533
|
+
argsStart === null
|
|
1534
|
+
? null
|
|
1535
|
+
: (snapshot.argumentsValueEnd ?? content.length);
|
|
1536
|
+
const nextArgumentsChunk =
|
|
1537
|
+
argsStart === null || argsEnd === null
|
|
1538
|
+
? ""
|
|
1539
|
+
: content.substring(
|
|
1540
|
+
argsStart + incremental.emittedArgumentsLength,
|
|
1541
|
+
argsEnd,
|
|
1542
|
+
);
|
|
1543
|
+
|
|
1544
|
+
if (!incremental.startEmitted) {
|
|
1545
|
+
this.pendingToolCallDeltas.push({
|
|
1546
|
+
index: incremental.index,
|
|
1547
|
+
id: incremental.id,
|
|
1548
|
+
type: "function",
|
|
1549
|
+
function: {
|
|
1550
|
+
name: incremental.name,
|
|
1551
|
+
arguments: nextArgumentsChunk,
|
|
1552
|
+
},
|
|
1553
|
+
});
|
|
1554
|
+
incremental.startEmitted = true;
|
|
1555
|
+
incremental.emittedArgumentsLength += nextArgumentsChunk.length;
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (nextArgumentsChunk) {
|
|
1560
|
+
this.pendingToolCallDeltas.push({
|
|
1561
|
+
index: incremental.index,
|
|
1562
|
+
function: {
|
|
1563
|
+
arguments: nextArgumentsChunk,
|
|
1564
|
+
},
|
|
1565
|
+
});
|
|
1566
|
+
incremental.emittedArgumentsLength += nextArgumentsChunk.length;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private advanceMarkdownState(text: string): void {
|
|
1571
|
+
if (!text) return;
|
|
1572
|
+
this.markdownCodeDelimiterLength = advanceMarkdownCodeState(
|
|
1573
|
+
text,
|
|
1574
|
+
this.markdownCodeDelimiterLength,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
private emitVisibleText(result: ParserResult, text: string): void {
|
|
1579
|
+
if (!text) return;
|
|
1580
|
+
if (this.emittedToolCallCount === 0) {
|
|
1581
|
+
result.text += text;
|
|
1582
|
+
}
|
|
1583
|
+
this.advanceMarkdownState(text);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
private holdLeadIn(text: string): void {
|
|
1587
|
+
if (!text) return;
|
|
1588
|
+
this.pendingLeadIn += text;
|
|
1589
|
+
this.advanceMarkdownState(text);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
private isDeclaredToolName(name: string): boolean {
|
|
1593
|
+
return this.resolveDeclaredToolName(name) !== null;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private preserveLiteralToolCall(
|
|
1597
|
+
content: string,
|
|
1598
|
+
result: ParserResult,
|
|
1599
|
+
reason: string,
|
|
1600
|
+
closed = true,
|
|
1601
|
+
): void {
|
|
1602
|
+
const literalBlock = `${this.currentOpenTag}${content}${closed ? this.currentCloseTag : ""}`;
|
|
1603
|
+
if (isToolcallDebugEnabled()) {
|
|
1604
|
+
logger.debug("[parser] preserving literal tool_call block as text", {
|
|
1605
|
+
reason,
|
|
1606
|
+
openTag: this.currentOpenTag,
|
|
1607
|
+
contentPreview: content.trim().substring(0, 300),
|
|
1608
|
+
closed,
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (this.emittedToolCallCount === 0) {
|
|
1613
|
+
result.text += this.pendingLeadIn;
|
|
1614
|
+
result.text += literalBlock;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
this.advanceMarkdownState(literalBlock);
|
|
1618
|
+
this.pendingLeadIn = "";
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
private recordMalformedToolCall(
|
|
1622
|
+
content: string,
|
|
1623
|
+
options: {
|
|
1624
|
+
undeclaredNames?: string[];
|
|
1625
|
+
category?: "malformed" | "undeclared" | "truncated";
|
|
1626
|
+
failureReason?: string;
|
|
1627
|
+
recoveryAttempts?: string[];
|
|
1628
|
+
} = {},
|
|
1629
|
+
): void {
|
|
1630
|
+
this.malformedToolCalls.push({
|
|
1631
|
+
contentPreview: content.substring(0, 150),
|
|
1632
|
+
content: content.substring(0, 2000),
|
|
1633
|
+
contentLength: content.length,
|
|
1634
|
+
timestamp: Date.now(),
|
|
1635
|
+
undeclaredNames: options.undeclaredNames,
|
|
1636
|
+
category: options.category ?? "malformed",
|
|
1637
|
+
failureReason: options.failureReason,
|
|
1638
|
+
recoveryAttempts: options.recoveryAttempts,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
private extractUndeclaredNamesFromContent(text: string): string[] {
|
|
1643
|
+
const candidates: string[] = [];
|
|
1644
|
+
for (const m of text.matchAll(/"name"\s*:\s*"([^"]+)"/g)) {
|
|
1645
|
+
candidates.push(m[1]);
|
|
1646
|
+
}
|
|
1647
|
+
for (const m of text.matchAll(/<name[^>]*>\s*([^<]+?)\s*<\/name>/g)) {
|
|
1648
|
+
candidates.push(m[1]);
|
|
1649
|
+
}
|
|
1650
|
+
return [...new Set(candidates)].filter(
|
|
1651
|
+
(name) => !this.isDeclaredToolName(name),
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* True when a captured tool-call body reads like natural-language prose
|
|
1657
|
+
* (multiple words, no JSON/XML payload shape) rather than a malformed tool
|
|
1658
|
+
* payload. Model replies that explain the tool-call syntax frequently quote
|
|
1659
|
+
* `<tool_call>`, `</tool_call>` or a JSON example — when those get captured,
|
|
1660
|
+
* dropping them hides a legitimate answer, and treating them as malformed
|
|
1661
|
+
* triggers a spurious [SYSTEM CORRECTION] auto-retry.
|
|
1662
|
+
*/
|
|
1663
|
+
private looksLikeProseContent(content: string): boolean {
|
|
1664
|
+
const t = content.trim();
|
|
1665
|
+
if (!t) return false;
|
|
1666
|
+
// Real (even malformed) tool payloads start with JSON or XML shape.
|
|
1667
|
+
if (t.startsWith("{") || t.startsWith("[") || t.startsWith("<parameter") || t.startsWith("<name>")) {
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
// Prose: multiple whitespace-separated words, longer than a short tag
|
|
1671
|
+
// fragment like "NOT_JSON" (which must stay tracked as malformed).
|
|
1672
|
+
const words = t.split(/\s+/).filter(Boolean);
|
|
1673
|
+
return words.length >= 3 && t.length > 20;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
feed(chunk: string): ParserResult {
|
|
1677
|
+
if (isToolcallDebugEnabled()) {
|
|
1678
|
+
logger.debug("[parser] feed() called", {
|
|
1679
|
+
chunkLength: chunk.length,
|
|
1680
|
+
chunkPreview: chunk.substring(0, 200),
|
|
1681
|
+
bufferLength: this.buffer.length,
|
|
1682
|
+
insideTool: this.insideTool,
|
|
1683
|
+
emittedToolCallCount: this.emittedToolCallCount,
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
this.buffer += chunk;
|
|
1688
|
+
const result: ParserResult = {
|
|
1689
|
+
text: "",
|
|
1690
|
+
toolCalls: [],
|
|
1691
|
+
toolCallDeltas: [],
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
while (this.buffer.length > 0) {
|
|
1695
|
+
if (!this.insideTool) {
|
|
1696
|
+
const match = findNextToolOpenTagOutsideMarkdownCode(
|
|
1697
|
+
this.buffer,
|
|
1698
|
+
this.markdownCodeDelimiterLength,
|
|
1699
|
+
);
|
|
1700
|
+
if (match) {
|
|
1701
|
+
// Text before the tool call tag
|
|
1702
|
+
const textBefore = this.buffer.substring(0, match.index);
|
|
1703
|
+
if (isToolcallDebugEnabled()) {
|
|
1704
|
+
logger.debug("[parser] tool_call open tag detected", {
|
|
1705
|
+
matchIndex: match.index,
|
|
1706
|
+
openTag: match.openTag,
|
|
1707
|
+
textBeforeLength: textBefore.length,
|
|
1708
|
+
textBeforePreview: textBefore.substring(0, 100),
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
// Once a tool call appears, hold the lead-in text.
|
|
1712
|
+
// OpenAI-compatible clients expect the whole assistant turn to be
|
|
1713
|
+
// a structured tool_calls message when tools are invoked.
|
|
1714
|
+
this.holdLeadIn(textBefore);
|
|
1715
|
+
this.insideTool = true;
|
|
1716
|
+
this.currentOpenTag = match.openTag;
|
|
1717
|
+
this.startIncrementalToolCall();
|
|
1718
|
+
this.buffer = this.buffer.substring(
|
|
1719
|
+
match.index + match.openTag.length,
|
|
1720
|
+
);
|
|
1721
|
+
continue;
|
|
1722
|
+
} else {
|
|
1723
|
+
const missingOpenRecovery = findRecoverableMissingOpenToolCall(
|
|
1724
|
+
this.buffer,
|
|
1725
|
+
this.markdownCodeDelimiterLength,
|
|
1726
|
+
);
|
|
1727
|
+
if (missingOpenRecovery) {
|
|
1728
|
+
if (isToolcallDebugEnabled()) {
|
|
1729
|
+
logger.debug(
|
|
1730
|
+
"[parser] recovering tool_call with missing opening tag",
|
|
1731
|
+
{
|
|
1732
|
+
textBeforeLength: missingOpenRecovery.textBefore.length,
|
|
1733
|
+
candidatePreview: missingOpenRecovery.candidate.substring(
|
|
1734
|
+
0,
|
|
1735
|
+
200,
|
|
1736
|
+
),
|
|
1737
|
+
},
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
this.holdLeadIn(missingOpenRecovery.textBefore);
|
|
1741
|
+
this.currentOpenTag = TOOL_START_LITERAL;
|
|
1742
|
+
this.currentCloseTag = missingOpenRecovery.closeTag;
|
|
1743
|
+
this.buffer = this.buffer.substring(
|
|
1744
|
+
missingOpenRecovery.consumeLength,
|
|
1745
|
+
);
|
|
1746
|
+
this.processToolContent(missingOpenRecovery.candidate, result);
|
|
1747
|
+
this.currentOpenTag = TOOL_START_LITERAL;
|
|
1748
|
+
this.currentCloseTag = TOOL_CALL_CLOSE;
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// No full open tag found. Check for partial missing-open or open tag at end.
|
|
1753
|
+
const partialMissingOpenIdx = findPartialMissingOpenToolCallIndex(
|
|
1754
|
+
this.buffer,
|
|
1755
|
+
this.markdownCodeDelimiterLength,
|
|
1756
|
+
);
|
|
1757
|
+
const partialOpenIdx = findPartialToolOpenIndexOutsideMarkdownCode(
|
|
1758
|
+
this.buffer,
|
|
1759
|
+
this.markdownCodeDelimiterLength,
|
|
1760
|
+
);
|
|
1761
|
+
const partialIdx =
|
|
1762
|
+
partialMissingOpenIdx === -1
|
|
1763
|
+
? partialOpenIdx
|
|
1764
|
+
: partialOpenIdx === -1
|
|
1765
|
+
? partialMissingOpenIdx
|
|
1766
|
+
: Math.min(partialMissingOpenIdx, partialOpenIdx);
|
|
1767
|
+
const flushIndex =
|
|
1768
|
+
partialIdx === -1 ? this.buffer.length : partialIdx;
|
|
1769
|
+
if (flushIndex > 0) {
|
|
1770
|
+
const textToEmit = this.buffer.substring(0, flushIndex);
|
|
1771
|
+
this.emitVisibleText(result, textToEmit);
|
|
1772
|
+
this.buffer = this.buffer.substring(flushIndex);
|
|
1773
|
+
}
|
|
1774
|
+
if (isToolcallDebugEnabled() && partialIdx !== -1) {
|
|
1775
|
+
logger.debug(
|
|
1776
|
+
"[parser] partial tool_call candidate detected at end of buffer",
|
|
1777
|
+
{
|
|
1778
|
+
partialIdx,
|
|
1779
|
+
partialContent: this.buffer.substring(partialIdx),
|
|
1780
|
+
},
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
break;
|
|
1784
|
+
}
|
|
1785
|
+
} else {
|
|
1786
|
+
// Inside tool: look for a supported closing tag outside JSON strings.
|
|
1787
|
+
const endMatch = findToolEndOutsideJsonString(this.buffer);
|
|
1788
|
+
if (endMatch) {
|
|
1789
|
+
const endIdx = endMatch.index;
|
|
1790
|
+
const content = this.buffer.substring(0, endIdx);
|
|
1791
|
+
if (isToolcallDebugEnabled()) {
|
|
1792
|
+
logger.debug("[parser] tool_call close tag detected", {
|
|
1793
|
+
contentLength: content.length,
|
|
1794
|
+
contentPreview: content.substring(0, 300),
|
|
1795
|
+
closeTag: endMatch.tag,
|
|
1796
|
+
remainingBufferLength:
|
|
1797
|
+
this.buffer.length - endIdx - endMatch.tag.length,
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
this.emitIncrementalToolCallDeltas(content);
|
|
1801
|
+
this.buffer = this.buffer.substring(endIdx + endMatch.tag.length);
|
|
1802
|
+
this.currentCloseTag = endMatch.tag;
|
|
1803
|
+
this.processToolContent(content, result);
|
|
1804
|
+
this.insideTool = false;
|
|
1805
|
+
this.currentOpenTag = TOOL_START_LITERAL;
|
|
1806
|
+
this.currentCloseTag = TOOL_CALL_CLOSE;
|
|
1807
|
+
this.clearIncrementalToolCall();
|
|
1808
|
+
} else {
|
|
1809
|
+
this.emitIncrementalToolCallDeltas(this.buffer);
|
|
1810
|
+
if (isToolcallDebugEnabled()) {
|
|
1811
|
+
logger.debug("[parser] waiting for more data inside tool_call", {
|
|
1812
|
+
bufferLength: this.buffer.length,
|
|
1813
|
+
bufferPreview: this.buffer.substring(0, 200),
|
|
1814
|
+
toolCallDeltaCount: result.toolCallDeltas.length,
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
break; // Wait for more data
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
if (
|
|
1823
|
+
isToolcallDebugEnabled() &&
|
|
1824
|
+
(result.text ||
|
|
1825
|
+
result.toolCalls.length > 0 ||
|
|
1826
|
+
result.toolCallDeltas.length > 0)
|
|
1827
|
+
) {
|
|
1828
|
+
logger.debug("[parser] feed() result", {
|
|
1829
|
+
textLength: result.text.length,
|
|
1830
|
+
textPreview: result.text.substring(0, 100),
|
|
1831
|
+
toolCallsCount: result.toolCalls.length,
|
|
1832
|
+
toolCallNames: result.toolCalls.map((tc) => tc.name),
|
|
1833
|
+
toolCallDeltaCount: result.toolCallDeltas.length,
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
return result;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
flush(): ParserResult {
|
|
1841
|
+
if (isToolcallDebugEnabled()) {
|
|
1842
|
+
logger.debug("[parser] flush() called", {
|
|
1843
|
+
bufferLength: this.buffer.length,
|
|
1844
|
+
bufferPreview: this.buffer.substring(0, 200),
|
|
1845
|
+
insideTool: this.insideTool,
|
|
1846
|
+
pendingLeadInLength: this.pendingLeadIn.length,
|
|
1847
|
+
emittedToolCallCount: this.emittedToolCallCount,
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
const result: ParserResult = {
|
|
1852
|
+
text: "",
|
|
1853
|
+
toolCalls: [],
|
|
1854
|
+
toolCallDeltas: [],
|
|
1855
|
+
};
|
|
1856
|
+
if (!this.buffer && !this.pendingLeadIn) return result;
|
|
1857
|
+
|
|
1858
|
+
if (this.insideTool) {
|
|
1859
|
+
// Stream ended with unclosed <tool_call>. Try to recover.
|
|
1860
|
+
const rawTrimmed = this.buffer.trim();
|
|
1861
|
+
// When findToolEndOutsideJsonString defers on an unparseable close
|
|
1862
|
+
// marker, the tag stays in the buffer (it was never consumed). Strip a
|
|
1863
|
+
// trailing close tag before recovery so it cannot pollute recovered
|
|
1864
|
+
// argument values (e.g. `{"a": "1</tool_call>"}`). Genuine unclosed
|
|
1865
|
+
// streams (cut mid-payload) have no trailing tag, so this is a no-op
|
|
1866
|
+
// for them.
|
|
1867
|
+
const trimmed = rawTrimmed.replace(/<\/tool_calls?>$/i, "");
|
|
1868
|
+
if (trimmed.length > 0) {
|
|
1869
|
+
if (isToolcallDebugEnabled()) {
|
|
1870
|
+
logger.debug(
|
|
1871
|
+
"[parser] flush: attempting recovery of unclosed tool_call",
|
|
1872
|
+
{
|
|
1873
|
+
trimmedLength: trimmed.length,
|
|
1874
|
+
trimmedPreview: trimmed.substring(0, 300),
|
|
1875
|
+
},
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
this.emitIncrementalToolCallDeltas(this.buffer);
|
|
1879
|
+
// The repair chain (missing value quotes / array closes) also applies
|
|
1880
|
+
// here: when the close-tag scan defers on an unparseable candidate, the
|
|
1881
|
+
// buffer reaches flush and tryRecoverToolCall would otherwise skip the
|
|
1882
|
+
// narrow typo repairs that processToolContent runs.
|
|
1883
|
+
const repairedTrimmed = repairCommonMalformedToolJson(trimmed);
|
|
1884
|
+
const recovered =
|
|
1885
|
+
this.tryRecoverToolCall(repairedTrimmed) ||
|
|
1886
|
+
this.tryRecoverToolCall(trimmed) ||
|
|
1887
|
+
this.tryRecoverIncrementalToolCall(trimmed) ||
|
|
1888
|
+
this.lastChanceRecoverToolCall(trimmed);
|
|
1889
|
+
if (recovered) {
|
|
1890
|
+
if (isToolcallDebugEnabled()) {
|
|
1891
|
+
logger.debug("[parser] flush: recovery successful", {
|
|
1892
|
+
name: recovered.name,
|
|
1893
|
+
arguments: recovered.arguments,
|
|
1894
|
+
id: recovered.id,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
this.finalizeSuccessfulToolCall(recovered, result);
|
|
1898
|
+
} else {
|
|
1899
|
+
// Recovery failed. Do NOT emit an assistant-visible warning: the
|
|
1900
|
+
// bridge must never inject its own text into the user-facing reply.
|
|
1901
|
+
// The malformed call is still tracked so the stream auto-retry can
|
|
1902
|
+
// send a [SYSTEM CORRECTION] to Qwen in the upstream prompt.
|
|
1903
|
+
//
|
|
1904
|
+
// Prose guard (same rationale as processToolContent): if the
|
|
1905
|
+
// unclosed body reads like natural-language prose, it is a
|
|
1906
|
+
// legitimate (possibly truncated) reply, not a tool call. Emit it as
|
|
1907
|
+
// visible text instead of tracking it as malformed.
|
|
1908
|
+
if (this.looksLikeProseContent(trimmed)) {
|
|
1909
|
+
if (isToolcallDebugEnabled()) {
|
|
1910
|
+
logger.debug(
|
|
1911
|
+
"[parser] flush: prose captured as unclosed tool call; preserving as text",
|
|
1912
|
+
{
|
|
1913
|
+
contentLength: trimmed.length,
|
|
1914
|
+
contentPreview: trimmed.substring(0, 200),
|
|
1915
|
+
},
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
this.discardPendingToolCallDeltas();
|
|
1919
|
+
if (this.emittedToolCallCount === 0) {
|
|
1920
|
+
result.text += this.pendingLeadIn;
|
|
1921
|
+
result.text += this.buffer;
|
|
1922
|
+
}
|
|
1923
|
+
this.pendingLeadIn = "";
|
|
1924
|
+
} else {
|
|
1925
|
+
const toolName = this.extractToolNameFromTruncated(trimmed);
|
|
1926
|
+
this.discardPendingToolCallDeltas();
|
|
1927
|
+
const truncRecoveryAttempts = [
|
|
1928
|
+
"tryRecoverToolCall",
|
|
1929
|
+
"tryRecoverIncrementalToolCall",
|
|
1930
|
+
"lastChanceRecoverToolCall",
|
|
1931
|
+
];
|
|
1932
|
+
this.recordMalformedToolCall(trimmed, {
|
|
1933
|
+
category: "truncated",
|
|
1934
|
+
undeclaredNames:
|
|
1935
|
+
this.extractUndeclaredNamesFromContent(trimmed),
|
|
1936
|
+
failureReason:
|
|
1937
|
+
"stream ended before tool_call closing tag; content too incomplete to reconstruct",
|
|
1938
|
+
recoveryAttempts: truncRecoveryAttempts,
|
|
1939
|
+
});
|
|
1940
|
+
logger.warn(
|
|
1941
|
+
"[parser] Dropping unrecoverable unclosed tool call at end of stream",
|
|
1942
|
+
{
|
|
1943
|
+
toolName,
|
|
1944
|
+
category: "truncated",
|
|
1945
|
+
contentLength: trimmed.length,
|
|
1946
|
+
content: trimmed.substring(0, 2000),
|
|
1947
|
+
failureReason:
|
|
1948
|
+
"stream ended before tool_call closing tag; content too incomplete to reconstruct",
|
|
1949
|
+
recoveryAttempts: truncRecoveryAttempts,
|
|
1950
|
+
emittedToolCallsSoFar: this.emittedToolCallCount,
|
|
1951
|
+
},
|
|
1952
|
+
);
|
|
1953
|
+
if (
|
|
1954
|
+
this.emittedToolCallCount === 0 &&
|
|
1955
|
+
this.pendingLeadIn.trim().length > 0
|
|
1956
|
+
) {
|
|
1957
|
+
result.text += this.pendingLeadIn;
|
|
1958
|
+
}
|
|
1959
|
+
this.pendingLeadIn = "";
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
} else {
|
|
1963
|
+
// Empty tool call block - restore lead-in
|
|
1964
|
+
if (isToolcallDebugEnabled()) {
|
|
1965
|
+
logger.debug(
|
|
1966
|
+
"[parser] flush: empty tool call block, restoring lead-in",
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
this.discardPendingToolCallDeltas();
|
|
1970
|
+
if (
|
|
1971
|
+
this.emittedToolCallCount === 0 &&
|
|
1972
|
+
this.pendingLeadIn.trim().length > 0
|
|
1973
|
+
) {
|
|
1974
|
+
result.text += this.pendingLeadIn;
|
|
1975
|
+
}
|
|
1976
|
+
this.pendingLeadIn = "";
|
|
1977
|
+
}
|
|
1978
|
+
} else {
|
|
1979
|
+
// If we are not insideTool, the model may have emitted raw JSON tool calls
|
|
1980
|
+
// (e.g. `{"name":"read","arguments":{...}} {"name":"glob",...} ......`)
|
|
1981
|
+
// without wrapping them in <tool_call> tags, or left an incomplete `<tool_call` tag at the end.
|
|
1982
|
+
let textToProcess = this.buffer;
|
|
1983
|
+
|
|
1984
|
+
// 1. Strip any trailing orphaned `<tool_call` or `<qpx_call` prefix at the end of buffer
|
|
1985
|
+
// (e.g. model output `... <tool_call` without closing `>`)
|
|
1986
|
+
textToProcess = textToProcess.replace(/<\/?(?:tool_calls?|qpx_call)\b[^>]*$/i, "").trimEnd();
|
|
1987
|
+
|
|
1988
|
+
// 2. Extract any unwrapped JSON tool calls from the buffer
|
|
1989
|
+
const { toolCalls: unwrappedCalls, remainingText } =
|
|
1990
|
+
this.extractUnwrappedToolCalls(textToProcess);
|
|
1991
|
+
|
|
1992
|
+
if (unwrappedCalls.length > 0) {
|
|
1993
|
+
if (isToolcallDebugEnabled()) {
|
|
1994
|
+
logger.debug("[parser] flush: extracted unwrapped tool calls from buffer", {
|
|
1995
|
+
count: unwrappedCalls.length,
|
|
1996
|
+
names: unwrappedCalls.map((tc) => tc.name),
|
|
1997
|
+
remainingTextPreview: remainingText.substring(0, 100),
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
for (const tc of unwrappedCalls) {
|
|
2001
|
+
this.finalizeSuccessfulToolCall(tc, result);
|
|
2002
|
+
}
|
|
2003
|
+
// If there was prose before the unwrapped tool calls, emit it as visible text
|
|
2004
|
+
if (remainingText.trim().length > 0 && this.emittedToolCallCount === unwrappedCalls.length) {
|
|
2005
|
+
this.emitVisibleText(result, remainingText);
|
|
2006
|
+
}
|
|
2007
|
+
} else {
|
|
2008
|
+
this.emitVisibleText(result, textToProcess);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (isToolcallDebugEnabled()) {
|
|
2013
|
+
logger.debug("[parser] flush() result", {
|
|
2014
|
+
textLength: result.text.length,
|
|
2015
|
+
toolCallsCount: result.toolCalls.length,
|
|
2016
|
+
toolCallNames: result.toolCalls.map((tc) => tc.name),
|
|
2017
|
+
toolCallDeltaCount: result.toolCallDeltas.length,
|
|
2018
|
+
totalEmittedToolCalls: this.emittedToolCallCount,
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
this.buffer = "";
|
|
2023
|
+
this.insideTool = false;
|
|
2024
|
+
this.currentOpenTag = TOOL_START_LITERAL;
|
|
2025
|
+
this.currentCloseTag = TOOL_END;
|
|
2026
|
+
this.markdownCodeDelimiterLength = 0;
|
|
2027
|
+
this.clearIncrementalToolCall();
|
|
2028
|
+
return result;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
getEmittedToolCallCount(): number {
|
|
2032
|
+
return this.emittedToolCallCount;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
isInsideTool(): boolean {
|
|
2036
|
+
return this.insideTool;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Get any lead-in text that was captured before tool calls.
|
|
2041
|
+
* Useful for fallback content when tool calls fail to parse.
|
|
2042
|
+
*/
|
|
2043
|
+
getPendingLeadIn(): string {
|
|
2044
|
+
return this.pendingLeadIn;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// ─── Internal Methods ──────────────────────────────────────────────────────
|
|
2048
|
+
|
|
2049
|
+
private processToolContent(content: string, result: ParserResult): void {
|
|
2050
|
+
const t = content.trim();
|
|
2051
|
+
if (!t) {
|
|
2052
|
+
// Empty tool call - malformed. Restore lead-in if possible.
|
|
2053
|
+
logger.warn("[parser] Dropping empty tool call block");
|
|
2054
|
+
this.discardPendingToolCallDeltas();
|
|
2055
|
+
if (
|
|
2056
|
+
this.emittedToolCallCount === 0 &&
|
|
2057
|
+
this.pendingLeadIn.trim().length > 0
|
|
2058
|
+
) {
|
|
2059
|
+
result.text += this.pendingLeadIn;
|
|
2060
|
+
}
|
|
2061
|
+
this.pendingLeadIn = "";
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
if (isToolcallDebugEnabled()) {
|
|
2066
|
+
logger.debug("[parser] processToolContent: analyzing content", {
|
|
2067
|
+
contentLength: t.length,
|
|
2068
|
+
contentPreview: t.substring(0, 300),
|
|
2069
|
+
startsWithBrace: t.startsWith("{"),
|
|
2070
|
+
startsWithBracket: t.startsWith("["),
|
|
2071
|
+
hasName: t.includes('"name"') || t.includes("<name>"),
|
|
2072
|
+
hasArgs:
|
|
2073
|
+
t.includes('"arguments"') ||
|
|
2074
|
+
t.includes('"args"') ||
|
|
2075
|
+
t.includes("<parameter"),
|
|
2076
|
+
openTag: this.currentOpenTag,
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// 1) Try Hermes-style XML <parameter> format first
|
|
2081
|
+
const xmlParsed = parseXmlParameterToolCall(
|
|
2082
|
+
t,
|
|
2083
|
+
this.currentOpenTag,
|
|
2084
|
+
this.tools,
|
|
2085
|
+
);
|
|
2086
|
+
if (xmlParsed) {
|
|
2087
|
+
const resolvedXmlName = this.resolveDeclaredToolName(xmlParsed.name);
|
|
2088
|
+
if (!resolvedXmlName) {
|
|
2089
|
+
this.recordMalformedToolCall(content, {
|
|
2090
|
+
undeclaredNames: [xmlParsed.name],
|
|
2091
|
+
category: "undeclared",
|
|
2092
|
+
});
|
|
2093
|
+
this.preserveLiteralToolCall(
|
|
2094
|
+
content,
|
|
2095
|
+
result,
|
|
2096
|
+
`undeclared tool name: ${xmlParsed.name}`,
|
|
2097
|
+
);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
xmlParsed.name = resolvedXmlName;
|
|
2101
|
+
if (isToolcallDebugEnabled()) {
|
|
2102
|
+
logger.debug(
|
|
2103
|
+
"[parser] processToolContent: XML parameter format parsed successfully",
|
|
2104
|
+
{
|
|
2105
|
+
name: xmlParsed.name,
|
|
2106
|
+
arguments: xmlParsed.arguments,
|
|
2107
|
+
argsKeys: Object.keys(xmlParsed.arguments),
|
|
2108
|
+
},
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
this.finalizeSuccessfulToolCall(
|
|
2112
|
+
{
|
|
2113
|
+
id: `call_${crypto.randomUUID()}`,
|
|
2114
|
+
name: xmlParsed.name,
|
|
2115
|
+
arguments: xmlParsed.arguments,
|
|
2116
|
+
},
|
|
2117
|
+
result,
|
|
2118
|
+
);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// 2) Try JSON array format
|
|
2123
|
+
if (t.startsWith("[")) {
|
|
2124
|
+
if (isToolcallDebugEnabled()) {
|
|
2125
|
+
logger.debug(
|
|
2126
|
+
"[parser] processToolContent: attempting JSON array parse",
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
try {
|
|
2130
|
+
const arr = JSON.parse(t);
|
|
2131
|
+
const parsedCalls: ParsedToolCall[] = (Array.isArray(arr) ? arr : [])
|
|
2132
|
+
.map((item: unknown) => this.parseToolCall(item))
|
|
2133
|
+
.filter(
|
|
2134
|
+
(tc: ParsedToolCall | null): tc is ParsedToolCall => tc !== null,
|
|
2135
|
+
);
|
|
2136
|
+
|
|
2137
|
+
for (const tc of parsedCalls) {
|
|
2138
|
+
const resolvedName = this.resolveDeclaredToolName(tc.name);
|
|
2139
|
+
if (resolvedName) tc.name = resolvedName;
|
|
2140
|
+
}
|
|
2141
|
+
const undeclaredToolNames = parsedCalls
|
|
2142
|
+
.map((tc) => tc.name)
|
|
2143
|
+
.filter((name) => !this.isDeclaredToolName(name));
|
|
2144
|
+
if (undeclaredToolNames.length > 0) {
|
|
2145
|
+
this.recordMalformedToolCall(content, {
|
|
2146
|
+
undeclaredNames: undeclaredToolNames,
|
|
2147
|
+
category: "undeclared",
|
|
2148
|
+
});
|
|
2149
|
+
this.preserveLiteralToolCall(
|
|
2150
|
+
content,
|
|
2151
|
+
result,
|
|
2152
|
+
`undeclared tool names in array: ${undeclaredToolNames.join(", ")}`,
|
|
2153
|
+
);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
for (const tc of parsedCalls) {
|
|
2158
|
+
if (isToolcallDebugEnabled()) {
|
|
2159
|
+
logger.debug("[parser] processToolContent: array item parsed", {
|
|
2160
|
+
name: tc.name,
|
|
2161
|
+
arguments: tc.arguments,
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
this.finalizeSuccessfulToolCall(tc, result);
|
|
2165
|
+
}
|
|
2166
|
+
return;
|
|
2167
|
+
} catch (e) {
|
|
2168
|
+
if (isToolcallDebugEnabled()) {
|
|
2169
|
+
logger.debug("[parser] processToolContent: JSON array parse failed", {
|
|
2170
|
+
error: e instanceof Error ? e.message : String(e),
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
// Fall through to JSON object parsing
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// 3) Try JSON object format (single or multiple)
|
|
2178
|
+
if (t.startsWith("{") || t.includes('"name"')) {
|
|
2179
|
+
if (isToolcallDebugEnabled()) {
|
|
2180
|
+
logger.debug(
|
|
2181
|
+
"[parser] processToolContent: attempting JSON object parse",
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
let tcs = this.parseToolContent(t);
|
|
2185
|
+
if (tcs.length === 0) {
|
|
2186
|
+
const repaired = repairCommonMalformedToolJson(t);
|
|
2187
|
+
if (repaired !== t) {
|
|
2188
|
+
if (isToolcallDebugEnabled()) {
|
|
2189
|
+
logger.debug("[parser] Repaired narrow malformed tool JSON typo");
|
|
2190
|
+
}
|
|
2191
|
+
tcs = this.parseToolContent(repaired);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (tcs.length > 0) {
|
|
2195
|
+
for (const tc of tcs) {
|
|
2196
|
+
const resolvedName = this.resolveDeclaredToolName(tc.name);
|
|
2197
|
+
if (resolvedName) tc.name = resolvedName;
|
|
2198
|
+
}
|
|
2199
|
+
const undeclaredToolNames = tcs
|
|
2200
|
+
.map((tc) => tc.name)
|
|
2201
|
+
.filter((name) => !this.isDeclaredToolName(name));
|
|
2202
|
+
if (undeclaredToolNames.length > 0) {
|
|
2203
|
+
this.recordMalformedToolCall(content, {
|
|
2204
|
+
undeclaredNames: undeclaredToolNames,
|
|
2205
|
+
category: "undeclared",
|
|
2206
|
+
});
|
|
2207
|
+
this.preserveLiteralToolCall(
|
|
2208
|
+
content,
|
|
2209
|
+
result,
|
|
2210
|
+
`undeclared tool names: ${undeclaredToolNames.join(", ")}`,
|
|
2211
|
+
);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
for (const tc of tcs) {
|
|
2216
|
+
// Check for tool name from opening tag attribute
|
|
2217
|
+
if (!tc.name || tc.name === "") {
|
|
2218
|
+
const attrName = extractToolName(this.currentOpenTag, t);
|
|
2219
|
+
if (attrName) tc.name = attrName;
|
|
2220
|
+
}
|
|
2221
|
+
if (tc.name) {
|
|
2222
|
+
if (isToolcallDebugEnabled()) {
|
|
2223
|
+
logger.debug(
|
|
2224
|
+
"[parser] processToolContent: JSON object parsed successfully",
|
|
2225
|
+
{
|
|
2226
|
+
name: tc.name,
|
|
2227
|
+
arguments: tc.arguments,
|
|
2228
|
+
argsKeys: Object.keys(tc.arguments),
|
|
2229
|
+
},
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
this.finalizeSuccessfulToolCall(tc, result);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// 3b) Try to recover malformed JSON (missing opening brace/quote)
|
|
2240
|
+
if (!t.startsWith("{") && t.includes('"')) {
|
|
2241
|
+
const recovered = this.tryRecoverMalformedJson(t);
|
|
2242
|
+
if (recovered) {
|
|
2243
|
+
if (isToolcallDebugEnabled()) {
|
|
2244
|
+
logger.debug(
|
|
2245
|
+
"[parser] processToolContent: recovered malformed JSON",
|
|
2246
|
+
{
|
|
2247
|
+
name: recovered.name,
|
|
2248
|
+
arguments: recovered.arguments,
|
|
2249
|
+
originalPreview: t.substring(0, 100),
|
|
2250
|
+
},
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
this.finalizeSuccessfulToolCall(recovered, result);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const incrementalRecovered = this.tryRecoverIncrementalToolCall(t);
|
|
2259
|
+
if (incrementalRecovered) {
|
|
2260
|
+
if (isToolcallDebugEnabled()) {
|
|
2261
|
+
logger.debug(
|
|
2262
|
+
"[parser] processToolContent: recovered incremental tool call",
|
|
2263
|
+
{
|
|
2264
|
+
name: incrementalRecovered.name,
|
|
2265
|
+
arguments: incrementalRecovered.arguments,
|
|
2266
|
+
},
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
this.finalizeSuccessfulToolCall(incrementalRecovered, result);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// 4) Last-chance recovery before giving up. Handles the payloads that
|
|
2274
|
+
// slip past the paths above:
|
|
2275
|
+
// - JSON with escaped quotes (`\"`) so the `"name"` probes miss it, e.g.
|
|
2276
|
+
// the model emitting a double-encoded string
|
|
2277
|
+
// `"{\"name\":\"edit_file\",...}"` or raw text wrapping a tool payload.
|
|
2278
|
+
// - JSON truncated by an early `</tool_call>` inside a string value (the
|
|
2279
|
+
// unbalanced-quote fallback close). robustParseJSON truncates to the
|
|
2280
|
+
// balanced prefix / closes missing braces; brace-matching extracts the
|
|
2281
|
+
// first balanced object from surrounding junk.
|
|
2282
|
+
const lastChance = this.lastChanceRecoverToolCall(t);
|
|
2283
|
+
if (lastChance) {
|
|
2284
|
+
if (isToolcallDebugEnabled()) {
|
|
2285
|
+
logger.debug(
|
|
2286
|
+
"[parser] processToolContent: last-chance recovery succeeded",
|
|
2287
|
+
{
|
|
2288
|
+
name: lastChance.name,
|
|
2289
|
+
arguments: lastChance.arguments,
|
|
2290
|
+
},
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
this.finalizeSuccessfulToolCall(lastChance, result);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// 5) Tool call is malformed and unrecoverable.
|
|
2298
|
+
// Never leak internal XML to user-visible content.
|
|
2299
|
+
// Restore lead-in text if no tools were emitted.
|
|
2300
|
+
//
|
|
2301
|
+
// Prose guard: when the captured body reads like natural-language prose
|
|
2302
|
+
// (model explaining the tool-call syntax, quoting markers/JSON), it is not
|
|
2303
|
+
// a malformed tool call — it is a legitimate reply that must reach the
|
|
2304
|
+
// client. Emit it as visible text and skip the malformed tracking so the
|
|
2305
|
+
// stream does not fire a spurious [SYSTEM CORRECTION] auto-retry.
|
|
2306
|
+
if (this.looksLikeProseContent(t)) {
|
|
2307
|
+
if (isToolcallDebugEnabled()) {
|
|
2308
|
+
logger.debug(
|
|
2309
|
+
"[parser] processToolContent: prose captured as tool call; preserving as text",
|
|
2310
|
+
{
|
|
2311
|
+
contentLength: t.length,
|
|
2312
|
+
contentPreview: t.substring(0, 200),
|
|
2313
|
+
},
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
this.discardPendingToolCallDeltas();
|
|
2317
|
+
if (this.emittedToolCallCount === 0) {
|
|
2318
|
+
result.text += this.pendingLeadIn;
|
|
2319
|
+
result.text += content;
|
|
2320
|
+
}
|
|
2321
|
+
this.pendingLeadIn = "";
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const droppedToolName = this.extractToolNameFromTruncated(t);
|
|
2326
|
+
const recoveryAttempts = [
|
|
2327
|
+
"directParse",
|
|
2328
|
+
"repairCommonMalformedToolJson",
|
|
2329
|
+
"tryRecoverMalformedJson",
|
|
2330
|
+
"tryRecoverIncrementalToolCall",
|
|
2331
|
+
"lastChanceRecoverToolCall",
|
|
2332
|
+
];
|
|
2333
|
+
this.recordMalformedToolCall(t, {
|
|
2334
|
+
undeclaredNames: this.extractUndeclaredNamesFromContent(t),
|
|
2335
|
+
category: "malformed",
|
|
2336
|
+
failureReason: "all recovery stages failed to produce valid JSON",
|
|
2337
|
+
recoveryAttempts,
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
logger.warn(
|
|
2341
|
+
`[parser] Dropping malformed tool call (${t.length} chars): ${t.substring(0, 80).replace(/\n/g, " ")}...`,
|
|
2342
|
+
{
|
|
2343
|
+
toolName: droppedToolName,
|
|
2344
|
+
category: "malformed",
|
|
2345
|
+
contentLength: t.length,
|
|
2346
|
+
content: t.substring(0, 2000),
|
|
2347
|
+
failureReason: "all recovery stages failed to produce valid JSON",
|
|
2348
|
+
recoveryAttempts,
|
|
2349
|
+
declaredTools: [...this.declaredToolNames].slice(0, 10),
|
|
2350
|
+
},
|
|
2351
|
+
);
|
|
2352
|
+
if (
|
|
2353
|
+
this.emittedToolCallCount === 0 &&
|
|
2354
|
+
this.pendingLeadIn.trim().length > 0
|
|
2355
|
+
) {
|
|
2356
|
+
result.text += this.pendingLeadIn;
|
|
2357
|
+
}
|
|
2358
|
+
this.pendingLeadIn = "";
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
private tryRecoverToolCall(block: string): ParsedToolCall | null {
|
|
2362
|
+
if (isToolcallDebugEnabled()) {
|
|
2363
|
+
logger.debug("[parser] tryRecoverToolCall: starting recovery attempts", {
|
|
2364
|
+
blockLength: block.length,
|
|
2365
|
+
blockPreview: block.substring(0, 300),
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Try full parse first
|
|
2370
|
+
const xmlParsed = parseXmlParameterToolCall(
|
|
2371
|
+
block,
|
|
2372
|
+
this.currentOpenTag,
|
|
2373
|
+
this.tools,
|
|
2374
|
+
);
|
|
2375
|
+
if (xmlParsed) {
|
|
2376
|
+
const resolvedXmlName = this.resolveDeclaredToolName(xmlParsed.name);
|
|
2377
|
+
if (!resolvedXmlName) {
|
|
2378
|
+
if (isToolcallDebugEnabled()) {
|
|
2379
|
+
logger.debug(
|
|
2380
|
+
"[parser] tryRecoverToolCall: rejecting undeclared XML tool name",
|
|
2381
|
+
{
|
|
2382
|
+
name: xmlParsed.name,
|
|
2383
|
+
},
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
return null;
|
|
2387
|
+
}
|
|
2388
|
+
xmlParsed.name = resolvedXmlName;
|
|
2389
|
+
if (isToolcallDebugEnabled()) {
|
|
2390
|
+
logger.debug("[parser] tryRecoverToolCall: full XML parse succeeded", {
|
|
2391
|
+
name: xmlParsed.name,
|
|
2392
|
+
arguments: xmlParsed.arguments,
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
id: `call_${crypto.randomUUID()}`,
|
|
2397
|
+
name: xmlParsed.name,
|
|
2398
|
+
arguments: xmlParsed.arguments,
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// Try recoverable (unclosed parameters)
|
|
2403
|
+
const recovered = parseRecoverableXmlToolCall(
|
|
2404
|
+
block,
|
|
2405
|
+
this.currentOpenTag,
|
|
2406
|
+
this.tools,
|
|
2407
|
+
);
|
|
2408
|
+
if (recovered) {
|
|
2409
|
+
const resolvedRecoveredName = this.resolveDeclaredToolName(
|
|
2410
|
+
recovered.name,
|
|
2411
|
+
);
|
|
2412
|
+
if (!resolvedRecoveredName) {
|
|
2413
|
+
if (isToolcallDebugEnabled()) {
|
|
2414
|
+
logger.debug(
|
|
2415
|
+
"[parser] tryRecoverToolCall: rejecting undeclared recoverable XML tool name",
|
|
2416
|
+
{
|
|
2417
|
+
name: recovered.name,
|
|
2418
|
+
},
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2423
|
+
recovered.name = resolvedRecoveredName;
|
|
2424
|
+
if (isToolcallDebugEnabled()) {
|
|
2425
|
+
logger.debug(
|
|
2426
|
+
"[parser] tryRecoverToolCall: recoverable XML parse succeeded",
|
|
2427
|
+
{
|
|
2428
|
+
name: recovered.name,
|
|
2429
|
+
arguments: recovered.arguments,
|
|
2430
|
+
},
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
return {
|
|
2434
|
+
id: `call_${crypto.randomUUID()}`,
|
|
2435
|
+
name: recovered.name,
|
|
2436
|
+
arguments: recovered.arguments,
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Try JSON (single or multiple)
|
|
2441
|
+
const jsonParsed = this.parseToolContent(block);
|
|
2442
|
+
if (jsonParsed.length > 0) {
|
|
2443
|
+
const first = jsonParsed[0];
|
|
2444
|
+
const attrName = extractToolName(this.currentOpenTag, block);
|
|
2445
|
+
if (attrName && !first.name) first.name = attrName;
|
|
2446
|
+
if (first.name) {
|
|
2447
|
+
const resolvedFirstName = this.resolveDeclaredToolName(first.name);
|
|
2448
|
+
if (!resolvedFirstName) {
|
|
2449
|
+
if (isToolcallDebugEnabled()) {
|
|
2450
|
+
logger.debug(
|
|
2451
|
+
"[parser] tryRecoverToolCall: rejecting undeclared JSON tool name",
|
|
2452
|
+
{
|
|
2453
|
+
name: first.name,
|
|
2454
|
+
},
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
return null;
|
|
2458
|
+
}
|
|
2459
|
+
first.name = resolvedFirstName;
|
|
2460
|
+
if (isToolcallDebugEnabled()) {
|
|
2461
|
+
logger.debug("[parser] tryRecoverToolCall: JSON parse succeeded", {
|
|
2462
|
+
name: first.name,
|
|
2463
|
+
arguments: first.arguments,
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
return first;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
if (isToolcallDebugEnabled()) {
|
|
2471
|
+
logger.debug("[parser] tryRecoverToolCall: all recovery attempts failed");
|
|
2472
|
+
}
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* Extract tool name from a truncated JSON buffer.
|
|
2478
|
+
* Used to provide a more informative warning when a tool call is dropped.
|
|
2479
|
+
*/
|
|
2480
|
+
private extractToolNameFromTruncated(buffer: string): string | null {
|
|
2481
|
+
// Try JSON format: {"name": "tool_name", ...}
|
|
2482
|
+
const jsonMatch = buffer.match(/"name"\s*:\s*"([^"]+)"/);
|
|
2483
|
+
if (jsonMatch) return jsonMatch[1];
|
|
2484
|
+
// Try XML format: <tool_call name="tool_name">
|
|
2485
|
+
const xmlMatch = buffer.match(/name="([^"]+)"/);
|
|
2486
|
+
if (xmlMatch) return xmlMatch[1];
|
|
2487
|
+
return null;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Try to recover malformed JSON that's missing opening brace/quote.
|
|
2492
|
+
* Example: `name": "read", "arguments": {"backend/package.json"}}`
|
|
2493
|
+
*/
|
|
2494
|
+
private tryRecoverMalformedJson(str: string): ParsedToolCall | null {
|
|
2495
|
+
if (isJsonPayloadTruncated(str)) return null;
|
|
2496
|
+
// Try adding {" at the beginning if it looks like a truncated JSON
|
|
2497
|
+
if (str.includes('"name"') || str.includes('name":')) {
|
|
2498
|
+
const candidates = [
|
|
2499
|
+
`{"${str}`, // Missing {"
|
|
2500
|
+
`{${str}`, // Missing {
|
|
2501
|
+
`"${str}`, // Missing "
|
|
2502
|
+
];
|
|
2503
|
+
|
|
2504
|
+
for (const candidate of candidates) {
|
|
2505
|
+
try {
|
|
2506
|
+
const parsed = robustParseJSON(candidate);
|
|
2507
|
+
if (parsed && typeof parsed === "object") {
|
|
2508
|
+
const name =
|
|
2509
|
+
parsed.name ||
|
|
2510
|
+
parsed.function?.name ||
|
|
2511
|
+
parsed.tool_name ||
|
|
2512
|
+
parsed.tool;
|
|
2513
|
+
if (name && typeof name === "string") {
|
|
2514
|
+
const resolvedName = this.resolveDeclaredToolName(name) ?? name;
|
|
2515
|
+
let args =
|
|
2516
|
+
parsed.arguments ||
|
|
2517
|
+
parsed.function?.arguments ||
|
|
2518
|
+
parsed.args ||
|
|
2519
|
+
parsed.parameters ||
|
|
2520
|
+
parsed.input ||
|
|
2521
|
+
{};
|
|
2522
|
+
if (typeof args === "string") {
|
|
2523
|
+
args = parseJsonishString(args) ?? {};
|
|
2524
|
+
}
|
|
2525
|
+
if (typeof args !== "object" || args === null) args = {};
|
|
2526
|
+
args = this.normalizeArgumentsForTool(
|
|
2527
|
+
resolvedName,
|
|
2528
|
+
args as Record<string, unknown>,
|
|
2529
|
+
);
|
|
2530
|
+
|
|
2531
|
+
if (isToolcallDebugEnabled()) {
|
|
2532
|
+
logger.debug("[parser] tryRecoverMalformedJson: success", {
|
|
2533
|
+
name: resolvedName,
|
|
2534
|
+
argsKeys: Object.keys(args),
|
|
2535
|
+
method:
|
|
2536
|
+
candidate === candidates[0]
|
|
2537
|
+
? 'add-{"'
|
|
2538
|
+
: candidate === candidates[1]
|
|
2539
|
+
? "add-{"
|
|
2540
|
+
: 'add-"',
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
return {
|
|
2545
|
+
id: `call_${crypto.randomUUID()}`,
|
|
2546
|
+
name: resolvedName,
|
|
2547
|
+
arguments: args,
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
} catch {
|
|
2552
|
+
// Try next candidate
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/**
|
|
2561
|
+
* Try to recover tool calls from payloads that escaped the normal paths:
|
|
2562
|
+
* double-escaped JSON (`\"` inside the block) and JSON truncated by a
|
|
2563
|
+
* premature closing tag inside a string value. Both robustParseJSON (which
|
|
2564
|
+
* starts at the first `{` and balances braces) and balanced-brace
|
|
2565
|
+
* extraction are attempted, on the raw and unescaped variants.
|
|
2566
|
+
*/
|
|
2567
|
+
private lastChanceRecoverToolCall(block: string): ParsedToolCall | null {
|
|
2568
|
+
// A structurally truncated payload must NOT be robust-recovered: it would
|
|
2569
|
+
// stream a cut call to the client and skip the auto-retry. Drop it so the
|
|
2570
|
+
// malformed tracking fires and the model re-emits cleanly.
|
|
2571
|
+
if (isJsonPayloadTruncated(block)) return null;
|
|
2572
|
+
const variants = [block];
|
|
2573
|
+
if (block.includes('\\"')) {
|
|
2574
|
+
variants.push(block.replace(/\\"/g, '"'));
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
for (const variant of variants) {
|
|
2578
|
+
try {
|
|
2579
|
+
const parsed = robustParseJSON(variant);
|
|
2580
|
+
if (parsed && typeof parsed === "object") {
|
|
2581
|
+
const tc = this.parseToolCall(parsed);
|
|
2582
|
+
if (tc && this.isDeclaredToolName(tc.name)) return tc;
|
|
2583
|
+
}
|
|
2584
|
+
} catch {}
|
|
2585
|
+
|
|
2586
|
+
try {
|
|
2587
|
+
const extracted = this.extractJsonToolCallByBraceMatching(variant);
|
|
2588
|
+
if (extracted) {
|
|
2589
|
+
const tc = this.parseToolCall(extracted);
|
|
2590
|
+
if (tc && this.isDeclaredToolName(tc.name)) return tc;
|
|
2591
|
+
}
|
|
2592
|
+
} catch {}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
private parseToolContent(str: string): ParsedToolCall[] {
|
|
2599
|
+
const calls: ParsedToolCall[] = [];
|
|
2600
|
+
|
|
2601
|
+
if (isToolcallDebugEnabled()) {
|
|
2602
|
+
logger.debug("[parser] parseToolContent: starting parse", {
|
|
2603
|
+
inputLength: str.length,
|
|
2604
|
+
inputPreview: str.substring(0, 200),
|
|
2605
|
+
hasNewlines: str.includes("\n"),
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// Try parsing as single JSON first. Some models return a JSON object with
|
|
2610
|
+
// every quote escaped (e.g. {\\"name\\":...}) after serializing a tool
|
|
2611
|
+
// call into text. Retry that representation without altering the normal
|
|
2612
|
+
// valid-JSON path.
|
|
2613
|
+
const jsonCandidates = [str];
|
|
2614
|
+
if (str.includes('\\"')) {
|
|
2615
|
+
jsonCandidates.push(str.replace(/\\"/g, '"'));
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
// Never robust-recover a structurally TRUNCATED payload: robustParseJSON
|
|
2619
|
+
// balances unclosed strings and would accept the cut as valid, silently
|
|
2620
|
+
// streaming a broken call to the client while skipping the malformed
|
|
2621
|
+
// auto-retry. Truncated payloads fall through to malformed tracking.
|
|
2622
|
+
if (!isJsonPayloadTruncated(str)) {
|
|
2623
|
+
for (const candidate of jsonCandidates) {
|
|
2624
|
+
try {
|
|
2625
|
+
const parsed = robustParseJSON(candidate);
|
|
2626
|
+
if (parsed && typeof parsed === "object") {
|
|
2627
|
+
const tc = this.parseToolCall(parsed);
|
|
2628
|
+
if (tc) {
|
|
2629
|
+
if (isToolcallDebugEnabled()) {
|
|
2630
|
+
logger.debug(
|
|
2631
|
+
"[parser] parseToolContent: single JSON parse succeeded",
|
|
2632
|
+
{
|
|
2633
|
+
name: tc.name,
|
|
2634
|
+
arguments: tc.arguments,
|
|
2635
|
+
unescapedCandidate: candidate !== str,
|
|
2636
|
+
},
|
|
2637
|
+
);
|
|
2638
|
+
}
|
|
2639
|
+
calls.push(tc);
|
|
2640
|
+
break;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
} catch (e) {
|
|
2644
|
+
if (isToolcallDebugEnabled()) {
|
|
2645
|
+
logger.debug("[parser] parseToolContent: single JSON parse failed", {
|
|
2646
|
+
error: e instanceof Error ? e.message : String(e),
|
|
2647
|
+
unescapedCandidate: candidate !== str,
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// Always try line-by-line parsing for multi-JSON content (independent of single parse)
|
|
2655
|
+
if (str.includes("\n")) {
|
|
2656
|
+
const lines = str
|
|
2657
|
+
.split("\n")
|
|
2658
|
+
.map((l) => l.trim())
|
|
2659
|
+
.filter((l) => l.startsWith("{") && l.endsWith("}"));
|
|
2660
|
+
if (isToolcallDebugEnabled()) {
|
|
2661
|
+
logger.debug(
|
|
2662
|
+
"[parser] parseToolContent: attempting line-by-line parse",
|
|
2663
|
+
{
|
|
2664
|
+
candidateLines: lines.length,
|
|
2665
|
+
},
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
for (const line of lines) {
|
|
2669
|
+
try {
|
|
2670
|
+
const parsed = JSON.parse(line);
|
|
2671
|
+
if (parsed && typeof parsed === "object") {
|
|
2672
|
+
const tc = this.parseToolCall(parsed);
|
|
2673
|
+
if (
|
|
2674
|
+
tc &&
|
|
2675
|
+
!calls.some(
|
|
2676
|
+
(c) =>
|
|
2677
|
+
c.name === tc.name &&
|
|
2678
|
+
JSON.stringify(c.arguments) === JSON.stringify(tc.arguments),
|
|
2679
|
+
)
|
|
2680
|
+
) {
|
|
2681
|
+
if (isToolcallDebugEnabled()) {
|
|
2682
|
+
logger.debug(
|
|
2683
|
+
"[parser] parseToolContent: line-by-line parse succeeded",
|
|
2684
|
+
{
|
|
2685
|
+
name: tc.name,
|
|
2686
|
+
arguments: tc.arguments,
|
|
2687
|
+
},
|
|
2688
|
+
);
|
|
2689
|
+
}
|
|
2690
|
+
calls.push(tc);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
} catch (e) {
|
|
2694
|
+
if (isToolcallDebugEnabled()) {
|
|
2695
|
+
logger.debug(
|
|
2696
|
+
"[parser] parseToolContent: line-by-line parse failed",
|
|
2697
|
+
{
|
|
2698
|
+
line: line.substring(0, 100),
|
|
2699
|
+
error: e instanceof Error ? e.message : String(e),
|
|
2700
|
+
},
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// Fallback: extract JSON tool call via balanced-brace search for large
|
|
2708
|
+
// payloads. Same truncation gate as the single-JSON parse: a structurally
|
|
2709
|
+
// truncated payload must not be robust-recovered (it would stream a cut
|
|
2710
|
+
// call and skip the malformed auto-retry).
|
|
2711
|
+
if (calls.length === 0 && str.includes('"name"') && !isJsonPayloadTruncated(str)) {
|
|
2712
|
+
const extracted = this.extractJsonToolCallByBraceMatching(str);
|
|
2713
|
+
if (extracted) {
|
|
2714
|
+
const tc = this.parseToolCall(extracted);
|
|
2715
|
+
if (
|
|
2716
|
+
tc &&
|
|
2717
|
+
!calls.some(
|
|
2718
|
+
(c) =>
|
|
2719
|
+
c.name === tc.name &&
|
|
2720
|
+
JSON.stringify(c.arguments) === JSON.stringify(tc.arguments),
|
|
2721
|
+
)
|
|
2722
|
+
) {
|
|
2723
|
+
if (isToolcallDebugEnabled()) {
|
|
2724
|
+
logger.debug(
|
|
2725
|
+
"[parser] parseToolContent: brace-matching extraction succeeded",
|
|
2726
|
+
{
|
|
2727
|
+
name: tc.name,
|
|
2728
|
+
},
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
calls.push(tc);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (isToolcallDebugEnabled()) {
|
|
2737
|
+
logger.debug("[parser] parseToolContent: result", {
|
|
2738
|
+
totalParsed: calls.length,
|
|
2739
|
+
names: calls.map((c) => c.name),
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
return calls;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Extract a JSON object from a string
|
|
2747
|
+
private extractJsonToolCallByBraceMatching(str: string): any | null {
|
|
2748
|
+
const startIdx = str.indexOf("{");
|
|
2749
|
+
if (startIdx === -1) return null;
|
|
2750
|
+
|
|
2751
|
+
let depth = 0;
|
|
2752
|
+
let inString = false;
|
|
2753
|
+
let escaped = false;
|
|
2754
|
+
|
|
2755
|
+
for (let i = startIdx; i < str.length; i++) {
|
|
2756
|
+
const c = str[i];
|
|
2757
|
+
if (escaped) {
|
|
2758
|
+
escaped = false;
|
|
2759
|
+
continue;
|
|
2760
|
+
}
|
|
2761
|
+
if (c === "\\") {
|
|
2762
|
+
escaped = true;
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
if (c === '"') {
|
|
2766
|
+
inString = !inString;
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
if (!inString) {
|
|
2770
|
+
if (c === "{") depth++;
|
|
2771
|
+
else if (c === "}") {
|
|
2772
|
+
depth--;
|
|
2773
|
+
if (depth === 0) {
|
|
2774
|
+
const candidate = str.substring(startIdx, i + 1);
|
|
2775
|
+
try {
|
|
2776
|
+
return JSON.parse(candidate);
|
|
2777
|
+
} catch {
|
|
2778
|
+
// Try robust parse on the extracted substring
|
|
2779
|
+
try {
|
|
2780
|
+
return robustParseJSON(candidate);
|
|
2781
|
+
} catch {
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// try closing remaining braces
|
|
2791
|
+
if (depth > 0) {
|
|
2792
|
+
const candidate = str.substring(startIdx) + "}".repeat(depth);
|
|
2793
|
+
try {
|
|
2794
|
+
return JSON.parse(candidate);
|
|
2795
|
+
} catch {
|
|
2796
|
+
try {
|
|
2797
|
+
return robustParseJSON(candidate);
|
|
2798
|
+
} catch {
|
|
2799
|
+
return null;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
private isHallucinatedToolCall(parsed: any): boolean {
|
|
2808
|
+
const args =
|
|
2809
|
+
parsed.arguments ||
|
|
2810
|
+
parsed.function?.arguments ||
|
|
2811
|
+
parsed.args ||
|
|
2812
|
+
parsed.parameters ||
|
|
2813
|
+
parsed.input ||
|
|
2814
|
+
{};
|
|
2815
|
+
const values =
|
|
2816
|
+
typeof args === "string"
|
|
2817
|
+
? [args]
|
|
2818
|
+
: typeof args === "object" && args !== null
|
|
2819
|
+
? Object.values(args).filter((v) => typeof v === "string") as string[]
|
|
2820
|
+
: [];
|
|
2821
|
+
for (const val of values) {
|
|
2822
|
+
// Detect vertical hallucination: single chars separated by newlines
|
|
2823
|
+
// e.g. "f\ni\ne\nl\nd\ns" or "a\nc\nf\ng\ne\nt..." (5+ single-char lines)
|
|
2824
|
+
// and zero-width / ornament chars inserted by WAF/bx
|
|
2825
|
+
const lines = val.split("\n");
|
|
2826
|
+
if (lines.length >= 8) {
|
|
2827
|
+
let singleCharLines = 0;
|
|
2828
|
+
for (const line of lines) {
|
|
2829
|
+
const trimmed = line.replace(/[\u200B\uFEFF¨\u00A8]/g, "").trim();
|
|
2830
|
+
if (trimmed.length === 1 && /^[A-Za-z0-9=_\-;()]$/.test(trimmed)) {
|
|
2831
|
+
singleCharLines++;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
if (singleCharLines >= 6 && singleCharLines / lines.length > 0.5) {
|
|
2835
|
+
return true;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
// Also catch the compact form "f\ni\ne..." after JSON parsing already
|
|
2839
|
+
// converted literal newlines to \n -> string contains "\n" per char
|
|
2840
|
+
if (/^([A-Za-z0-9=_\-;()]\n){6,}/.test(val) || /(\w\n){8,}/.test(val)) {
|
|
2841
|
+
return true;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
return false;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
private parseToolCall(parsed: any): ParsedToolCall | null {
|
|
2848
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2849
|
+
|
|
2850
|
+
const name =
|
|
2851
|
+
parsed.name || parsed.function?.name || parsed.tool_name || parsed.tool;
|
|
2852
|
+
if (!name || typeof name !== "string" || name.length === 0) return null;
|
|
2853
|
+
|
|
2854
|
+
// Drop hallucinated tool calls where the model split a value vertically
|
|
2855
|
+
// (e.g. "fields" -> "f\ni\ne\nl\nd\ns"). These are valid JSON after
|
|
2856
|
+
// sanitizeAndBalance but semantically broken; treat as malformed so the
|
|
2857
|
+
// [SYSTEM CORRECTION] auto-retry fires instead of delivering garbage.
|
|
2858
|
+
if (this.isHallucinatedToolCall(parsed)) {
|
|
2859
|
+
return null;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
let args =
|
|
2863
|
+
parsed.arguments ||
|
|
2864
|
+
parsed.function?.arguments ||
|
|
2865
|
+
parsed.args ||
|
|
2866
|
+
parsed.parameters ||
|
|
2867
|
+
parsed.input ||
|
|
2868
|
+
{};
|
|
2869
|
+
if (typeof args === "string") {
|
|
2870
|
+
args = parseJsonishString(args) ?? {};
|
|
2871
|
+
}
|
|
2872
|
+
if (typeof args !== "object" || args === null) args = {};
|
|
2873
|
+
|
|
2874
|
+
// Recover flattened tool calls where the model put the parameters at the
|
|
2875
|
+
// top level instead of inside an `arguments`/`params` wrapper, e.g.
|
|
2876
|
+
// `{"name":"write_file","path":"...","content":"..."}`. Only do this when
|
|
2877
|
+
// no explicit args wrapper was present.
|
|
2878
|
+
if (Object.keys(args).length === 0) {
|
|
2879
|
+
const reservedKeys = new Set([
|
|
2880
|
+
"name",
|
|
2881
|
+
"type",
|
|
2882
|
+
"id",
|
|
2883
|
+
"tool_call_id",
|
|
2884
|
+
"function",
|
|
2885
|
+
"tool_name",
|
|
2886
|
+
"tool",
|
|
2887
|
+
"raw",
|
|
2888
|
+
]);
|
|
2889
|
+
const flattened: Record<string, unknown> = {};
|
|
2890
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
2891
|
+
if (!reservedKeys.has(key)) {
|
|
2892
|
+
flattened[key] = value;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
if (Object.keys(flattened).length > 0) {
|
|
2896
|
+
args = flattened;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
const resolvedName = this.resolveDeclaredToolName(name) ?? name;
|
|
2901
|
+
args = this.normalizeArgumentsForTool(resolvedName, args);
|
|
2902
|
+
|
|
2903
|
+
return {
|
|
2904
|
+
id: parsed.id || parsed.tool_call_id || `call_${crypto.randomUUID()}`,
|
|
2905
|
+
name: resolvedName,
|
|
2906
|
+
arguments: args,
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/**
|
|
2911
|
+
* Scans a text buffer for one or more unwrapped raw JSON tool calls
|
|
2912
|
+
* (e.g. `{"name":"read","arguments":{...}} {"name":"glob",...} ......`)
|
|
2913
|
+
* and extracts them cleanly without letting raw JSON leak into user-facing text.
|
|
2914
|
+
*/
|
|
2915
|
+
public extractUnwrappedToolCalls(
|
|
2916
|
+
text: string,
|
|
2917
|
+
): { toolCalls: ParsedToolCall[]; remainingText: string } {
|
|
2918
|
+
const trimmed = text.trim();
|
|
2919
|
+
if (!trimmed.includes('"name"') && !trimmed.includes('name":') && !trimmed.includes("'name'")) {
|
|
2920
|
+
return { toolCalls: [], remainingText: text };
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
const toolCalls: ParsedToolCall[] = [];
|
|
2924
|
+
let remainingText = "";
|
|
2925
|
+
let i = 0;
|
|
2926
|
+
|
|
2927
|
+
while (i < text.length) {
|
|
2928
|
+
if (text[i] === "{") {
|
|
2929
|
+
const jsonEnd = findMatchingClosingBrace(text, i);
|
|
2930
|
+
if (jsonEnd !== -1) {
|
|
2931
|
+
const candidate = text.substring(i, jsonEnd + 1);
|
|
2932
|
+
const recovered =
|
|
2933
|
+
this.tryRecoverToolCall(candidate) ||
|
|
2934
|
+
this.lastChanceRecoverToolCall(candidate);
|
|
2935
|
+
if (recovered && this.isDeclaredToolName(recovered.name)) {
|
|
2936
|
+
toolCalls.push(recovered);
|
|
2937
|
+
i = jsonEnd + 1;
|
|
2938
|
+
continue;
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
remainingText += text[i];
|
|
2943
|
+
i++;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
if (toolCalls.length > 0) {
|
|
2947
|
+
// Strip trailing hallucinated ellipsis dots/spaces (e.g. `...... ......`)
|
|
2948
|
+
remainingText = remainingText.replace(/(\s*\.{2,}\s*)+$/g, "").trimEnd();
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
return { toolCalls, remainingText };
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* String and escape-aware scanner to find the matching closing brace '}'
|
|
2957
|
+
* for a JSON object starting at `startIdx`.
|
|
2958
|
+
*/
|
|
2959
|
+
function findMatchingClosingBrace(text: string, startIdx: number): number {
|
|
2960
|
+
let depth = 0;
|
|
2961
|
+
let inString = false;
|
|
2962
|
+
let escape = false;
|
|
2963
|
+
let quoteChar = "";
|
|
2964
|
+
|
|
2965
|
+
for (let j = startIdx; j < text.length; j++) {
|
|
2966
|
+
const ch = text[j];
|
|
2967
|
+
if (inString) {
|
|
2968
|
+
if (escape) {
|
|
2969
|
+
escape = false;
|
|
2970
|
+
} else if (ch === "\\") {
|
|
2971
|
+
escape = true;
|
|
2972
|
+
} else if (ch === quoteChar) {
|
|
2973
|
+
inString = false;
|
|
2974
|
+
}
|
|
2975
|
+
} else {
|
|
2976
|
+
if (ch === '"' || ch === "'") {
|
|
2977
|
+
inString = true;
|
|
2978
|
+
quoteChar = ch;
|
|
2979
|
+
} else if (ch === "{") {
|
|
2980
|
+
depth++;
|
|
2981
|
+
} else if (ch === "}") {
|
|
2982
|
+
depth--;
|
|
2983
|
+
if (depth === 0) return j;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
return -1;
|
|
2989
|
+
}
|