const QUOTE = 0x22; const BACKSLASH = 0x5c; const U = 0x75; const SQUOTE = 0x27; // Valid chars after `\`: " \ / b f n r t u const VALID_ESCAPE_CHAR = new Uint8Array(128); for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1; const CONTROL_ESCAPES: readonly string[] = (() => { const e: string[] = []; e[0x08] = "\\b"; e[0x09] = "\\t"; e[0x0a] = "\\n"; e[0x0c] = "\\f"; e[0x0d] = "\\r"; for (let cp = 0; cp <= 0x1f; cp++) { e[cp] ??= `\\u${cp.toString(16).padStart(4, "0")}`; } return e; })(); const HEX4_RE = /^[0-9a-fA-F]{4}$/; function isHexDigit(cp: number): boolean { return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66); } function isWhitespace(cp: number): boolean { return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d; } function isIdentChar(cp: number): boolean { return ( (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x7a) || cp === 0x5f /* _ */ || cp === 0x24 /* $ */ ); } /** Bareword literals: standard JSON plus Python `True`/`False`/`None`. */ const KEYWORDS: readonly (readonly [string, unknown])[] = [ ["true", true], ["false", false], ["null", null], ["True", true], ["False", false], ["None", null], ]; /** * JS-only atoms never recovered as bareword strings — a tool must not execute * with a non-finite or undefined argument masquerading as a string. */ const NON_RECOVERABLE_BAREWORDS: Record = { NaN: true, Infinity: true, "-Infinity": true, "+Infinity": true, undefined: true, }; /** * Sentinel returned by partial-mode value parsing when an atomic value * (number / keyword) is incomplete at the streaming edge, so the enclosing * object/array rolls back to the last valid prefix instead of committing junk. */ const INCOMPLETE = Symbol("incomplete"); /** * Lightweight string-level repair of the escape/control-char hazards that make * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the * input unchanged when no repair is needed. Pure string→string; does not parse. */ export function repairJson(json: string): string { const len = json.length; const parts: string[] = []; let lastEmit = 0; let inString = false; let i = 0; while (i < len) { if (!inString) { // Fast scan: skip to next quote. while (i < len && json.charCodeAt(i) !== QUOTE) i++; if (i >= len) break; inString = true; i++; continue; } // Fast scan inside string: advance past chars that need no handling. while (i < len) { const cp = json.charCodeAt(i); if (cp < 0x20 || cp === QUOTE || cp === BACKSLASH) break; i++; } if (i >= len) break; const cp = json.charCodeAt(i); if (cp === QUOTE) { inString = false; i++; continue; } if (cp === BACKSLASH) { // Need at least one char after the backslash; treat EOI as invalid escape. if (i + 1 >= len) { parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } const nextCp = json.charCodeAt(i + 1); if (nextCp === U) { // Need full \uXXXX, all four digits, all hex. if ( i + 5 < len && isHexDigit(json.charCodeAt(i + 2)) && isHexDigit(json.charCodeAt(i + 3)) && isHexDigit(json.charCodeAt(i + 4)) && isHexDigit(json.charCodeAt(i + 5)) ) { i += 6; continue; } // Truncated or non-hex \u — escape the backslash, re-process the rest. parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } if (nextCp < 128 && VALID_ESCAPE_CHAR[nextCp] === 1) { i += 2; continue; } parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } // Control character (cp < 0x20). parts.push(json.slice(lastEmit, i), CONTROL_ESCAPES[cp]); lastEmit = i + 1; i++; } if (!parts.length) return json; if (lastEmit < len) parts.push(json.slice(lastEmit)); return parts.join(""); } /** * Recursive-descent parser for a forgiving superset of JSON. Beyond strict JSON * it accepts, and normalizes, the malformations LLM tool-call bodies leak in * practice: * * - single-quoted strings and unquoted object keys (JSON5); * - trailing / stray commas, and `//` + block comments; * - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`; * - raw control characters and invalid `\x` escapes inside strings (kept literally); * - unescaped quotes inside strings — a quote only closes a string when followed * by a value terminator, recovering apostrophes such as `'it's'`; * - unquoted string values in object/array value position (strict mode only) — * an unrecognized bareword such as `{"paths": packages/foo/*}` is recovered as * a string up to the next `,` / `}` / `]` / newline. * * In `partial` mode an unterminated string/object/array (or a value cut off at * end-of-input) is auto-closed with whatever was parsed so far — for streaming. * In strict mode, end-of-input mid-value and trailing garbage both throw, so a * final parse never silently accepts a half-formed tool call. */ class RelaxedJson { readonly #s: string; readonly #n: number; readonly #partial: boolean; #i = 0; constructor(source: string, partial: boolean) { this.#s = source; this.#n = source.length; this.#partial = partial; } parse(): unknown { this.#ws(); if (this.#i >= this.#n) { if (this.#partial) return undefined; throw new SyntaxError("Unexpected end of JSON input"); } const value = this.#value(false); if (value === INCOMPLETE) return undefined; this.#ws(); if (!this.#partial && this.#i < this.#n) { throw new SyntaxError(`Unexpected trailing characters at position ${this.#i}`); } return value; } #ws(): void { const s = this.#s; for (;;) { while (this.#i < this.#n && isWhitespace(s.charCodeAt(this.#i))) this.#i++; if (this.#i + 1 < this.#n && s.charCodeAt(this.#i) === 0x2f /* / */) { const next = s.charCodeAt(this.#i + 1); if (next === 0x2f /* / line comment */) { this.#i += 2; while (this.#i < this.#n && s.charCodeAt(this.#i) !== 0x0a) this.#i++; continue; } if (next === 0x2a /* * block comment */) { this.#i += 2; while ( this.#i + 1 < this.#n && !(s.charCodeAt(this.#i) === 0x2a && s.charCodeAt(this.#i + 1) === 0x2f) ) { this.#i++; } this.#i = Math.min(this.#i + 2, this.#n); continue; } } break; } } #value(allowBareword: boolean): unknown { const s = this.#s; const c = s[this.#i]; if (c === "{") return this.#object(); if (c === "[") return this.#array(); if (c === '"' || c === "'") return this.#string(s.charCodeAt(this.#i)); const cc = s.charCodeAt(this.#i); if (cc === 0x2d /* - */ || cc === 0x2b /* + */ || cc === 0x2e /* . */ || (cc >= 0x30 && cc <= 0x39)) { // JS-only NaN / Infinity are deliberately not accepted: a tool must not // execute with a non-finite numeric arg; they fall through #number's // NaN guard (strict throw / partial rollback) like other bad tokens. return this.#number(); } return this.#keyword(allowBareword); } #object(): Record { this.#i++; // consume { const out: Record = {}; for (;;) { this.#ws(); if (this.#i >= this.#n) { if (this.#partial) return out; throw new SyntaxError("Unterminated object"); } const c = this.#s[this.#i]; if (c === "}") { this.#i++; return out; } if (c === ",") { // Tolerate leading / doubled / trailing commas. this.#i++; continue; } const key = this.#key(); this.#ws(); if (this.#i < this.#n && this.#s[this.#i] === ":") { this.#i++; } else if (this.#partial) { return out; } else { throw new SyntaxError("Expected ':' in object"); } this.#ws(); if (this.#i >= this.#n) { if (this.#partial) return out; throw new SyntaxError("Expected value after ':'"); } const value = this.#value(true); if (value === INCOMPLETE) return out; out[key] = value; this.#ws(); const d = this.#i < this.#n ? this.#s[this.#i] : ""; if (d === ",") { this.#i++; continue; } if (d === "}") { this.#i++; return out; } if (this.#partial) return out; throw new SyntaxError("Expected ',' or '}' in object"); } } #array(): unknown[] { this.#i++; // consume [ const out: unknown[] = []; for (;;) { this.#ws(); if (this.#i >= this.#n) { if (this.#partial) return out; throw new SyntaxError("Unterminated array"); } const c = this.#s[this.#i]; if (c === "]") { this.#i++; return out; } if (c === ",") { this.#i++; continue; } const value = this.#value(true); if (value === INCOMPLETE) return out; out.push(value); this.#ws(); const d = this.#i < this.#n ? this.#s[this.#i] : ""; if (d === ",") { this.#i++; continue; } if (d === "]") { this.#i++; return out; } if (this.#partial) return out; throw new SyntaxError("Expected ',' or ']' in array"); } } #key(): string { const c = this.#s[this.#i]; if (c === '"' || c === "'") return this.#string(this.#s.charCodeAt(this.#i)); // Unquoted identifier key: read until a structural delimiter / whitespace. const start = this.#i; while (this.#i < this.#n) { const ch = this.#s[this.#i]; if (ch === ":" || ch === "," || ch === "}" || isWhitespace(this.#s.charCodeAt(this.#i))) break; this.#i++; } if (this.#i === start) { if (this.#partial) return ""; throw new SyntaxError("Expected object key"); } return this.#s.slice(start, this.#i); } #string(quote: number): string { const s = this.#s; const n = this.#n; let i = this.#i + 1; // skip opening quote let out = ""; let runStart = i; while (i < n) { const cc = s.charCodeAt(i); if (cc !== BACKSLASH && cc !== quote) { i++; continue; } if (cc === quote) { // Apostrophe / inner-quote recovery (a quote that isn't followed by a // value terminator is literal) is safe for single quotes and in partial // mode. For double quotes in strict mode, close on the first unescaped // quote like standard JSON so malformed structure fails loudly instead // of silently swallowing commas/colons into one string. const lenient = quote === SQUOTE || this.#partial; if (!lenient || this.#closesString(i + 1)) { out += s.slice(runStart, i); this.#i = i + 1; return out; } // Unescaped inner quote (e.g. apostrophe in `'it's'`) — keep it literal. i++; continue; } // Backslash escape. out += s.slice(runStart, i); i++; if (i >= n) { out += "\\"; runStart = i; break; } const esc = s.charCodeAt(i); switch (esc) { case QUOTE: out += '"'; break; case SQUOTE: out += "'"; break; case BACKSLASH: out += "\\"; break; case 0x2f: out += "/"; break; case 0x62: out += "\b"; break; case 0x66: out += "\f"; break; case 0x6e: out += "\n"; break; case 0x72: out += "\r"; break; case 0x74: out += "\t"; break; case U: { const hex = s.slice(i + 1, i + 5); if (HEX4_RE.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 4; } else { out += "\\u"; // invalid \u — keep literal } break; } default: out += `\\${s[i]}`; // invalid escape — keep backslash literal } i++; runStart = i; } out += s.slice(runStart, i); if (this.#partial) { this.#i = i; return out; } throw new SyntaxError("Unterminated string"); } /** A quote closes a string only when the next non-space char ends a value. */ #closesString(from: number): boolean { const s = this.#s; let k = from; while (k < this.#n && isWhitespace(s.charCodeAt(k))) k++; if (k >= this.#n) return true; const c = s[k]; return c === "," || c === "}" || c === "]" || c === ":"; } #number(): unknown { const s = this.#s; const start = this.#i; while (this.#i < this.#n) { const ch = s[this.#i]; if ( (ch >= "0" && ch <= "9") || ch === "-" || ch === "+" || ch === "." || ch === "e" || ch === "E" || ch === "x" || ch === "X" || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F") ) { this.#i++; } else { break; } } const token = s.slice(start, this.#i); const num = Number(token); if (Number.isNaN(num)) { if (this.#partial) return INCOMPLETE; throw new SyntaxError(`Invalid number: ${token}`); } return num; } #keyword(allowBareword: boolean): unknown { const s = this.#s; const i = this.#i; for (const [word, value] of KEYWORDS) { // Require a non-identifier boundary so `Truex` / `nullish` are not misread // as the keyword followed by junk. if (s.startsWith(word, i) && !isIdentChar(s.charCodeAt(i + word.length))) { this.#i += word.length; return value; } } if (this.#partial) { // Incomplete / unrecognized atomic token at the streaming edge — signal the // caller to roll back to the last valid prefix instead of committing junk. this.#i = this.#n; return INCOMPLETE; } if (allowBareword) return this.#bareword(); throw new SyntaxError(`Unexpected token at position ${this.#i}`); } /** * Strict-mode recovery of an unquoted string value, e.g. * `{"paths": packages/foo/*}`: consume until `,` / `}` / `]` / newline and * trim trailing whitespace. Recovery still throws — so a final parse never * accepts a half-formed or non-finite argument — when the token: * - hits end-of-input before a delimiter (truncated value); * - contains a `"`, `{`, `[`, or a key-like `:` — this parser accepts * unquoted keys, so a missed comma (`{"a": foo "b": 1}`, `{a: foo b: 1}`) * would otherwise silently swallow the following field. A colon followed * by `/` or `\` stays literal so URL and Windows-path values recover; * - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}). */ #bareword(): string { const s = this.#s; const start = this.#i; let i = start; while (i < this.#n) { const cc = s.charCodeAt(i); if (cc === 0x2c /* , */ || cc === 0x7d /* } */ || cc === 0x5d /* ] */ || cc === 0x0a || cc === 0x0d) break; if ( cc === QUOTE || cc === 0x7b /* { */ || cc === 0x5b /* [ */ || (cc === 0x3a /* : */ && s.charCodeAt(i + 1) !== 0x2f /* / */ && s.charCodeAt(i + 1) !== 0x5c) /* \ */ ) { throw new SyntaxError(`Unexpected token at position ${start}`); } i++; } if (i >= this.#n) throw new SyntaxError(`Unexpected token at position ${start}`); let end = i; while (end > start && isWhitespace(s.charCodeAt(end - 1))) end--; const word = s.slice(start, end); if (NON_RECOVERABLE_BAREWORDS[word]) throw new SyntaxError(`Unexpected token at position ${start}`); this.#i = i; return word; } } /** * Final-parse a JSON value, repairing the common LLM malformations * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON * semantics), then the relaxed parser. Throws when the input is unrepairable, * truncated, or carries trailing garbage — so callers can skip a bad tool call * rather than execute a half-formed one. */ export function parseJsonWithRepair(json: string): T { try { return JSON.parse(json) as T; } catch { return new RelaxedJson(json, false).parse() as T; } } /** * Parse possibly-incomplete JSON during streaming. Always returns a value, never * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed * best-effort object for truncated ones. */ export function parseStreamingJson>(partialJson: string | undefined): T { const trimmed = partialJson?.trimStart(); if (!trimmed) return {} as T; try { return JSON.parse(trimmed) as T; } catch { try { return (new RelaxedJson(trimmed, true).parse() ?? {}) as T; } catch { return {} as T; } } } /** * Default minimum byte growth before `parseStreamingJsonThrottled` will * re-parse a streaming tool-call argument buffer. Acts as the floor of the * geometric gate — see {@link parseStreamingJsonThrottled}. */ export const STREAMING_JSON_PARSE_MIN_GROWTH = 256; /** * Throttled variant of {@link parseStreamingJson} for the per-delta hot path. * * Tool calls arrive as a long sequence of small deltas — calling * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer * each time, giving O(N²) work in the total buffer length. A fixed re-parse * floor alone does NOT fix this: with `minGrowthBytes` constant, a buffer of * length N is parsed N/minGrowthBytes times at an average cost of N/2, which * is still O(N²) (the constant just shrinks). Long `write` payloads — where * the buffer is the whole file — made this the dominant main-thread stall * during streaming. * * Instead the gate scales geometrically: once the buffer is large, a re-parse * requires growth proportional to the current length (`len / 32`, floored at * `minGrowthBytes`). Parse points then form a geometric progression, so a * buffer of length N is parsed O(log N) times for O(N log N) total work, * while small buffers keep the snappy fixed-cadence updates. * * Each provider tracks the last parsed length on its tool-call block, so the * final `toolcall_end` parse (which providers already perform unconditionally) * is the authoritative full parse — the throttle only delays mid-stream UI * updates, by at most ~3% of the accumulated content for large buffers. * * @returns the parsed object plus the new `parsedLen` to persist; or `null` * when the buffer has not grown enough to warrant a re-parse. */ export function parseStreamingJsonThrottled>( partialJson: string | undefined, lastParsedLen: number, minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH, ): { value: T; parsedLen: number } | null { const len = partialJson?.length ?? 0; if (len === 0) return null; const growth = Math.max(minGrowthBytes, len >> 5); if (lastParsedLen > 0 && len - lastParsedLen < growth) return null; return { value: parseStreamingJson(partialJson), parsedLen: len }; } /** * Classification of a streaming buffer against strict JSON (RFC 8259): * - `"complete"`: exactly one whole JSON value (plus surrounding whitespace). * - `"prefix"`: a proper prefix of some valid JSON value — more bytes can * still complete it. * - `"invalid"`: no suffix can ever make it valid strict JSON (e.g. a raw * control character inside a string, or a second top-level value). */ export type JsonPrefixState = "complete" | "prefix" | "invalid"; /** What the strict-prefix scanner expects at the current position. */ const enum JsonExpect { Value, ObjKeyOrEnd, ObjKey, ObjColon, ObjCommaOrEnd, ArrValueOrEnd, ArrCommaOrEnd, End, } /** * Classify `text` as a strict-JSON value, prefix, or dead end. * * Providers use this to disambiguate identifierless streaming tool-call * deltas: a chunk starting with `{` is a *new* sibling call only if the * current call's argument buffer cannot absorb it — the buffer is already a * complete value, already unsalvageable (lossy hosts abandon buffers * mid-string, leaving raw control characters strict JSON forbids), or the * concatenation would break it. Unlike {@link parseStreamingJson} this is * deliberately strict: forgiving repair would mask exactly the corruption * signals the caller needs. * * A top-level number at end-of-input classifies as `"complete"` even though * more digits could extend it; tool-argument buffers are always objects, so * the ambiguity is immaterial here. */ export function classifyJsonPrefix(text: string): JsonPrefixState { const n = text.length; let i = 0; // Container stack: true = object, false = array. const stack: boolean[] = []; let expect = JsonExpect.Value; /** Consume a string starting at the opening quote. 1 = ok, 0 = prefix, -1 = invalid. */ const scanString = (): 1 | 0 | -1 => { i++; // opening quote while (i < n) { const c = text.charCodeAt(i); if (c === QUOTE) { i++; return 1; } if (c === BACKSLASH) { i++; if (i >= n) return 0; const e = text.charCodeAt(i); if (e >= 128 || !VALID_ESCAPE_CHAR[e]) return -1; i++; if (e === U) { for (let k = 0; k < 4; k++, i++) { if (i >= n) return 0; if (!isHexDigit(text.charCodeAt(i))) return -1; } } continue; } if (c < 0x20) return -1; // raw control char: strict JSON forbids it i++; } return 0; }; /** Consume a number starting at `-` or a digit. 1 = token done, 0 = prefix, -1 = invalid. */ const scanNumber = (): 1 | 0 | -1 => { if (text.charCodeAt(i) === 0x2d) i++; // - if (i >= n) return 0; let c = text.charCodeAt(i); if (c === 0x30) { i++; // 0: no further integer digits allowed } else if (c >= 0x31 && c <= 0x39) { while (i < n && text.charCodeAt(i) >= 0x30 && text.charCodeAt(i) <= 0x39) i++; } else { return -1; } if (i < n && text.charCodeAt(i) === 0x2e) { i++; // . if (i >= n) return 0; if (text.charCodeAt(i) < 0x30 || text.charCodeAt(i) > 0x39) return -1; while (i < n && text.charCodeAt(i) >= 0x30 && text.charCodeAt(i) <= 0x39) i++; } c = i < n ? text.charCodeAt(i) : 0; if (c === 0x65 || c === 0x45) { i++; // e | E if (i < n && (text.charCodeAt(i) === 0x2b || text.charCodeAt(i) === 0x2d)) i++; if (i >= n) return 0; if (text.charCodeAt(i) < 0x30 || text.charCodeAt(i) > 0x39) return -1; while (i < n && text.charCodeAt(i) >= 0x30 && text.charCodeAt(i) <= 0x39) i++; } return 1; }; /** Consume `true`/`false`/`null`. 1 = done, 0 = prefix, -1 = invalid. */ const scanKeyword = (): 1 | 0 | -1 => { for (const word of ["true", "false", "null"] as const) { if (word.charCodeAt(0) !== text.charCodeAt(i)) continue; const available = Math.min(word.length, n - i); if (!word.startsWith(text.slice(i, i + available))) return -1; i += available; return available === word.length ? 1 : 0; } return -1; }; /** A value just finished; the next expectation follows from the stack. */ const valueDone = (): JsonExpect => stack.length === 0 ? JsonExpect.End : stack[stack.length - 1] ? JsonExpect.ObjCommaOrEnd : JsonExpect.ArrCommaOrEnd; while (i < n) { const c = text.charCodeAt(i); if (isWhitespace(c)) { i++; continue; } switch (expect) { case JsonExpect.Value: case JsonExpect.ArrValueOrEnd: { if (c === 0x5d && expect === JsonExpect.ArrValueOrEnd) { stack.pop(); i++; expect = valueDone(); break; } if (c === 0x7b) { stack.push(true); i++; expect = JsonExpect.ObjKeyOrEnd; break; } if (c === 0x5b) { stack.push(false); i++; expect = JsonExpect.ArrValueOrEnd; break; } let r: 1 | 0 | -1; if (c === QUOTE) r = scanString(); else if (c === 0x2d || (c >= 0x30 && c <= 0x39)) r = scanNumber(); else if (c === 0x74 || c === 0x66 || c === 0x6e) r = scanKeyword(); else return "invalid"; if (r === -1) return "invalid"; if (r === 0) return "prefix"; expect = valueDone(); break; } case JsonExpect.ObjKeyOrEnd: case JsonExpect.ObjKey: { if (c === 0x7d && expect === JsonExpect.ObjKeyOrEnd) { stack.pop(); i++; expect = valueDone(); break; } if (c !== QUOTE) return "invalid"; const r = scanString(); if (r === -1) return "invalid"; if (r === 0) return "prefix"; expect = JsonExpect.ObjColon; break; } case JsonExpect.ObjColon: if (c !== 0x3a) return "invalid"; i++; expect = JsonExpect.Value; break; case JsonExpect.ObjCommaOrEnd: if (c === 0x7d) { stack.pop(); i++; expect = valueDone(); break; } if (c !== 0x2c) return "invalid"; i++; expect = JsonExpect.ObjKey; break; case JsonExpect.ArrCommaOrEnd: if (c === 0x5d) { stack.pop(); i++; expect = valueDone(); break; } if (c !== 0x2c) return "invalid"; i++; expect = JsonExpect.Value; break; case JsonExpect.End: return "invalid"; // trailing non-whitespace after a complete value } } return expect === JsonExpect.End ? "complete" : "prefix"; }