{"version":3,"file":"truncated-json.d.ts","sourceRoot":"","sources":["../../../src/core/tool-call/truncated-json.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;;;;;;GASG;AAEH,eAAO,MAAM,iCAAiC,sCAAsC,CAAC;AAIrF,MAAM,WAAW,2BAA2B;IAC3C,OAAO,EAAE,oBAAoB,CAAC;IAC9B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsKlF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IACL,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACvD,GACJ,2BAA2B,CA0C7B;AAED,mFAAmF;AACnF,OAAO,EAAE,SAAS,IAAI,YAAY,EAAE,CAAC","sourcesContent":["import { sha256Hex } from \"./canonicalize.js\";\nimport type { TruncatedJsonOutcome } from \"./types.js\";\n\n/**\n * Bounded, deterministic recovery of truncated JSON tool-call arguments caused\n * by streaming interruption or provider truncation.\n *\n * Invariants:\n *  - Distinguishes incomplete JSON from invalid complete JSON.\n *  - Only closes structural containers / finishes a trailing string token.\n *  - NEVER fabricates missing field values or guessed keys.\n *  - Enforces a maximum repair size, maximum depth, and max continuations.\n */\n\nexport const TOOL_CALL_TRUNCATED_UNRECOVERABLE = \"TOOL_CALL_TRUNCATED_UNRECOVERABLE\";\n\nconst MAX_DEPTH = 512;\n\nexport interface TruncatedJsonRecoveryResult {\n\toutcome: TruncatedJsonOutcome;\n\t/** Recovered JSON value, when recovered. */\n\tvalue?: unknown;\n}\n\n/**\n * Scan a JSON text and, when it is an INCOMPLETE but coherent prefix (near the\n * end of a stream), produce the shortest deterministic completion by closing\n * open containers / a trailing value string. Returns null when the text is not\n * a recoverable incomplete JSON document.\n *\n * The scanner advances through whitespace-aware tokens and, at end-of-input,\n * verifies we ended in a position that admits a structural close.\n */\nexport function tryCloseTruncatedJson(raw: string, maxBytes: number): string | null {\n\tif (raw.length > maxBytes) return null;\n\n\tconst stack: (\"{\" | \"[\")[] = [];\n\tlet i = 0;\n\tconst n = raw.length;\n\n\t// Modes:\n\t//  awaitKey  -> inside object, expecting a key string or `}`\n\t//  key       -> reading a key string\n\t//  awaitColon-> just closed a key, expecting `:`\n\t//  awaitVal  -> expecting a value (root, after `:`, or after `[`/`,`)\n\t//  stringV   -> reading a string VALUE\n\t//  awaitSep  -> just completed a value, expecting `,` `}` `]` or whitespace\n\tlet mode: \"awaitKey\" | \"key\" | \"awaitColon\" | \"awaitVal\" | \"stringV\" | \"awaitSep\" = \"awaitVal\";\n\n\tconst scanLiteral = (): string | null => {\n\t\tif (raw.startsWith(\"true\", i)) return \"true\";\n\t\tif (raw.startsWith(\"false\", i)) return \"false\";\n\t\tif (raw.startsWith(\"null\", i)) return \"null\";\n\t\treturn null;\n\t};\n\n\twhile (i < n) {\n\t\tconst ch = raw[i];\n\t\tswitch (mode) {\n\t\t\tcase \"awaitVal\": {\n\t\t\t\tif (ch === \"{\") {\n\t\t\t\t\tstack.push(\"{\");\n\t\t\t\t\tmode = \"awaitKey\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"[\") {\n\t\t\t\t\tstack.push(\"[\");\n\t\t\t\t\tmode = \"awaitVal\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === '\"') {\n\t\t\t\t\tmode = \"stringV\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"t\" || ch === \"f\" || ch === \"n\") {\n\t\t\t\t\tconst lit = scanLiteral();\n\t\t\t\t\tif (!lit) return null;\n\t\t\t\t\ti += lit.length;\n\t\t\t\t\tmode = \"awaitSep\";\n\t\t\t\t} else if (ch === \"-\" || (ch >= \"0\" && ch <= \"9\")) {\n\t\t\t\t\tconst rest = raw.slice(i).match(/^-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?/);\n\t\t\t\t\tif (!rest) return null;\n\t\t\t\t\ti += rest[0].length;\n\t\t\t\t\tmode = \"awaitSep\";\n\t\t\t\t} else if (/\\s/.test(ch)) {\n\t\t\t\t\ti++;\n\t\t\t\t} else {\n\t\t\t\t\treturn null; // unexpected char where a value was expected\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"awaitKey\": {\n\t\t\t\tif (ch === '\"') {\n\t\t\t\t\tmode = \"key\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"}\") {\n\t\t\t\t\tstack.pop();\n\t\t\t\t\tmode = \"awaitSep\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (/\\s/.test(ch)) {\n\t\t\t\t\ti++;\n\t\t\t\t} else {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"key\": {\n\t\t\t\tif (ch === '\"') {\n\t\t\t\t\tmode = \"awaitColon\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"\\\\\") {\n\t\t\t\t\tif (i + 1 >= n) return null; // truncated escape within key\n\t\t\t\t\ti += 2;\n\t\t\t\t} else {\n\t\t\t\t\ti++;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"awaitColon\": {\n\t\t\t\tif (ch === \":\") {\n\t\t\t\t\tmode = \"awaitVal\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (/\\s/.test(ch)) {\n\t\t\t\t\ti++;\n\t\t\t\t} else {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stringV\": {\n\t\t\t\tif (ch === '\"') {\n\t\t\t\t\tmode = \"awaitSep\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"\\\\\") {\n\t\t\t\t\tif (i + 1 >= n) return null; // dangling backslash at eof\n\t\t\t\t\tif (raw[i + 1] === \"u\") {\n\t\t\t\t\t\tif (i + 5 >= n || !/^[0-9a-fA-F]{4}$/.test(raw.slice(i + 2, i + 6))) return null;\n\t\t\t\t\t\ti += 6;\n\t\t\t\t\t} else {\n\t\t\t\t\t\ti += 2;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\ti++;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"awaitSep\": {\n\t\t\t\tif (ch === \",\") {\n\t\t\t\t\tif (stack.length && stack[stack.length - 1] === \"{\") mode = \"awaitKey\";\n\t\t\t\t\telse mode = \"awaitVal\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (ch === \"}\" || ch === \"]\") {\n\t\t\t\t\tconst expected = ch === \"}\" ? \"{\" : \"[\";\n\t\t\t\t\tif (!stack.length || stack[stack.length - 1] !== expected) return null;\n\t\t\t\t\tstack.pop();\n\t\t\t\t\tmode = \"awaitSep\";\n\t\t\t\t\ti++;\n\t\t\t\t} else if (/\\s/.test(ch)) {\n\t\t\t\t\ti++;\n\t\t\t\t} else {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (stack.length > MAX_DEPTH) return null;\n\t}\n\n\t// At end-of-input. Decide whether the last token is a recoverable close.\n\tlet out = raw;\n\tif (mode === \"stringV\") {\n\t\t// A trailing value string without a closing quote. Only deterministic to\n\t\t// close inside an array value position (object values could be truncated\n\t\t// mid-value and closing the quote would be a guess about whether it is a\n\t\t// key or value is already resolved to value here, so it is safe).\n\t\tif (stack.length && stack[stack.length - 1] === \"[\") {\n\t\t\tout = `${raw}\"`;\n\t\t} else {\n\t\t\t// root string value or object value: incomplete but the container is\n\t\t\t// still open; final closing quote is a deterministic finishing token.\n\t\t\tout = `${raw}\"`;\n\t\t}\n\t} else if (mode === \"key\" || mode === \"awaitColon\") {\n\t\t// We are mid-key or waiting for colon: cannot fabricate the key.\n\t\treturn null;\n\t} else if (out.endsWith(\",\")) {\n\t\t// Trailing comma before a close is invalid JSON; we strip it.\n\t\tout = out.replace(/,(\\s*)$/, \"$1\");\n\t}\n\n\tif (stack.length) {\n\t\tfor (let s = stack.length - 1; s >= 0; s--) {\n\t\t\tout += stack[s] === \"{\" ? \"}\" : \"]\";\n\t\t}\n\t}\n\n\ttry {\n\t\tJSON.parse(out);\n\t} catch {\n\t\treturn null;\n\t}\n\treturn out;\n}\n\n/**\n * Deterministic recovery driver. Bounded by content size and continuation\n * attempts.\n */\nexport function recoverTruncatedJson(\n\traw: string,\n\topts: {\n\t\tmaxBytes?: number;\n\t\tmaxContinuations?: number;\n\t\tcontinuation?: (fragment: string) => Promise<string | null>;\n\t} = {},\n): TruncatedJsonRecoveryResult {\n\tconst maxBytes = opts.maxBytes ?? 256 * 1024;\n\tconst maxContinuations = opts.maxContinuations ?? 3;\n\tconst beforeHash = sha256Hex(raw);\n\n\tif (raw.length > maxBytes) {\n\t\treturn { outcome: { status: \"unrecoverable\", reason: \"exceeds_max_repair_size\", beforeHash } };\n\t}\n\n\ttry {\n\t\tJSON.parse(raw);\n\t\treturn { outcome: { status: \"not_truncated\" }, value: JSON.parse(raw) };\n\t} catch {\n\t\t// fall through\n\t}\n\n\tlet candidate = tryCloseTruncatedJson(raw, maxBytes);\n\tlet continuationAttempts = 0;\n\tif (candidate === null && opts.continuation) {\n\t\twhile (candidate === null && continuationAttempts < maxContinuations) {\n\t\t\tconst fragment = opts.continuation(raw).catch(() => null);\n\t\t\tif (fragment === null) {\n\t\t\t\tcontinuationAttempts++;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tconst combined = raw + fragment;\n\t\t\tcandidate = tryCloseTruncatedJson(combined, maxBytes);\n\t\t\tcontinuationAttempts++;\n\t\t}\n\t}\n\tif (candidate === null) {\n\t\treturn { outcome: { status: \"unrecoverable\", reason: \"structural_close_impossible\", beforeHash } };\n\t}\n\ttry {\n\t\tconst value = JSON.parse(candidate);\n\t\treturn {\n\t\t\toutcome: { status: \"recovered\", continuationAttempts, beforeHash, afterHash: sha256Hex(candidate) },\n\t\t\tvalue,\n\t\t};\n\t} catch {\n\t\treturn { outcome: { status: \"unrecoverable\", reason: \"recovered_but_invalid\", beforeHash } };\n\t}\n}\n\n/** Escape helper reused by diagnostics so raw fragments are hashed, not logged. */\nexport { sha256Hex as fragmentHash };\n"]}