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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. 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(/&quot;/g, '"')
540
+ .replace(/&apos;/g, "'")
541
+ .replace(/&lt;/g, "<")
542
+ .replace(/&gt;/g, ">")
543
+ .replace(/&amp;/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
+ }