@dtlabs/ai-toolkit 0.17.3 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/lib/workflow-control.js +55 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.17.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.3...v0.17.4) (2026-10-13)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* **workflow-control:** repair unescaped control chars in recovered JSON ([ebeddb5](https://github.com/dtlab-tech/ai-toolkit/commit/ebeddb5465ac988f94bd28d20b7f9dfdd967c2c9))
|
|
8
|
+
* **workflow-control:** repair unescaped control chars in recovered JSON ([0ce2466](https://github.com/dtlab-tech/ai-toolkit/commit/0ce24666b6d4d09b25ca71b1ed3b9f0ba7c06d7a))
|
|
9
|
+
|
|
2
10
|
## [0.17.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.2...v0.17.3) (2026-10-12)
|
|
3
11
|
|
|
4
12
|
|
package/lib/workflow-control.js
CHANGED
|
@@ -305,6 +305,41 @@ function createControl(options) {
|
|
|
305
305
|
return api;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// A model asked to emit a JSON blob as part of a chat reply (rather than through a real
|
|
309
|
+
// serializer, i.e. whenever structured_output didn't get populated and extraction has to
|
|
310
|
+
// recover the payload from free text) is prone to one specific, well-understood defect: a
|
|
311
|
+
// long free-text field (a "report") gets its literal line breaks typed as real newlines
|
|
312
|
+
// instead of the required `\n` escape. The JSON otherwise reads as entirely correct to a
|
|
313
|
+
// human, but JSON.parse rejects it outright ("Bad control character in string literal") —
|
|
314
|
+
// confirmed against a real run where the model's JSON was discarded for exactly this reason
|
|
315
|
+
// despite carrying the correct ```json fence tag and schema-correct structure. This is a
|
|
316
|
+
// narrow, mechanical repair for that one defect, not a lenient parser: it tracks whether
|
|
317
|
+
// each character lies inside a double-quoted JSON string literal (honoring \" and \\
|
|
318
|
+
// escapes) and re-escapes a raw newline/CR/tab ONLY there, leaving structural whitespace
|
|
319
|
+
// between tokens untouched. Applied as a fallback after a plain JSON.parse fails, never in
|
|
320
|
+
// place of one, so already-valid JSON is never second-guessed.
|
|
321
|
+
function _repairUnescapedControlCharsInStrings(text) {
|
|
322
|
+
let result = '';
|
|
323
|
+
let inString = false;
|
|
324
|
+
let escaped = false;
|
|
325
|
+
for (let i = 0; i < text.length; i++) {
|
|
326
|
+
const ch = text[i];
|
|
327
|
+
if (inString) {
|
|
328
|
+
if (escaped) { result += ch; escaped = false; }
|
|
329
|
+
else if (ch === '\\') { result += ch; escaped = true; }
|
|
330
|
+
else if (ch === '"') { result += ch; inString = false; }
|
|
331
|
+
else if (ch === '\n') { result += '\\n'; }
|
|
332
|
+
else if (ch === '\r') { result += '\\r'; }
|
|
333
|
+
else if (ch === '\t') { result += '\\t'; }
|
|
334
|
+
else { result += ch; }
|
|
335
|
+
} else {
|
|
336
|
+
if (ch === '"') inString = true;
|
|
337
|
+
result += ch;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
308
343
|
// The model is not consistent about which delimiter it uses to mark its final structured
|
|
309
344
|
// answer inside otherwise-free text — observed empirically, same agent and schema, run to
|
|
310
345
|
// run: an <structured_output> tag, a ```json fence, and an unprompted <json> tag. Growing
|
|
@@ -332,8 +367,15 @@ function extractDelimitedStructuredOutput(text, schema) {
|
|
|
332
367
|
const last = text.lastIndexOf('}');
|
|
333
368
|
if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
|
|
334
369
|
for (const raw of candidates) {
|
|
370
|
+
const trimmed = raw.trim();
|
|
371
|
+
let parsed;
|
|
372
|
+
try {
|
|
373
|
+
parsed = JSON.parse(trimmed);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
try { parsed = JSON.parse(_repairUnescapedControlCharsInStrings(trimmed)); }
|
|
376
|
+
catch (e2) { continue; }
|
|
377
|
+
}
|
|
335
378
|
try {
|
|
336
|
-
const parsed = JSON.parse(raw.trim());
|
|
337
379
|
validateSchema(parsed, schema);
|
|
338
380
|
return parsed;
|
|
339
381
|
} catch (e) { /* try the next candidate, or fall through to null */ }
|
|
@@ -354,7 +396,18 @@ function extractDelimitedStructuredOutput(text, schema) {
|
|
|
354
396
|
// remains the one thing that fails closed on it; this function only undoes wrapping, it is not
|
|
355
397
|
// itself a validation gate.
|
|
356
398
|
function extractJsonDocument(text) {
|
|
357
|
-
|
|
399
|
+
// Same "try the candidate as-is, then try it with raw control characters inside
|
|
400
|
+
// string literals re-escaped" fallback as extractDelimitedStructuredOutput, and
|
|
401
|
+
// for the identical reason: a long free-text field (e.g. a task description)
|
|
402
|
+
// typed with literal line breaks instead of `\n` is otherwise indistinguishable
|
|
403
|
+
// from genuinely malformed JSON, even though it's one mechanical fix away from
|
|
404
|
+
// valid. Returns the (possibly repaired) STRING to write to disk, never the
|
|
405
|
+
// parsed object — this function's contract is text recovery, not parsing.
|
|
406
|
+
const tryParse = candidate => {
|
|
407
|
+
try { JSON.parse(candidate); return candidate; } catch (e) {}
|
|
408
|
+
const repaired = _repairUnescapedControlCharsInStrings(candidate);
|
|
409
|
+
try { JSON.parse(repaired); return repaired; } catch (e) { return null; }
|
|
410
|
+
};
|
|
358
411
|
const whole = tryParse(text.trim());
|
|
359
412
|
if (whole != null) return whole;
|
|
360
413
|
const fenceMatches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
|