@oh-my-pi/pi-ai 16.4.4 → 16.4.5
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 +6 -0
- package/package.json +4 -4
- package/src/dialect/glm.ts +124 -1
- package/src/utils/validation.ts +216 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.5] - 2026-07-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed an issue in GLM tool calling where missing or malformed argument closers (such as `<arg_value>` mistyped as `</arg_key>`) caused subsequent arguments to be swallowed or merged into a single field, affecting both in-band and native tool calling.
|
|
10
|
+
|
|
5
11
|
## [16.4.3] - 2026-07-11
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.4.
|
|
4
|
+
"version": "16.4.5",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.4.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.4.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.4.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.4.5",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.4.5",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.4.5",
|
|
44
44
|
"arktype": "2.2.2",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/dialect/glm.ts
CHANGED
|
@@ -272,8 +272,19 @@ export class GLMInbandScanner implements InbandScanner {
|
|
|
272
272
|
|
|
273
273
|
#consumeValue(final: boolean, events: InbandScanEvent[]): boolean {
|
|
274
274
|
const close = this.#buffer.indexOf(ARG_VALUE_CLOSE);
|
|
275
|
+
const heal = scanValueHeal(this.#buffer, close === -1 ? this.#buffer.length : close);
|
|
276
|
+
if (heal.kind === "heal") {
|
|
277
|
+
this.#streamValue(this.#buffer.slice(0, heal.valueEnd), events);
|
|
278
|
+
if (heal.trimValue && this.#call) this.#call.valueRaw = this.#call.valueRaw.trimEnd();
|
|
279
|
+
this.#appendCallRaw(this.#buffer.slice(heal.valueEnd, heal.resumeAt));
|
|
280
|
+
this.#buffer = this.#buffer.slice(heal.resumeAt);
|
|
281
|
+
this.#endValue();
|
|
282
|
+
this.#state = "body";
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
275
285
|
if (close === -1) {
|
|
276
|
-
const
|
|
286
|
+
const healHold = heal.kind === "partial" ? this.#buffer.length - heal.start : 0;
|
|
287
|
+
const hold = final ? 0 : Math.max(partialSuffixOverlap(this.#buffer, ARG_VALUE_CLOSE), healHold);
|
|
277
288
|
const emit = this.#buffer.slice(0, this.#buffer.length - hold);
|
|
278
289
|
this.#streamValue(emit, events);
|
|
279
290
|
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
@@ -389,6 +400,118 @@ function minFound(...values: readonly number[]): number {
|
|
|
389
400
|
return best;
|
|
390
401
|
}
|
|
391
402
|
|
|
403
|
+
/** Max whitespace tolerated between heal-signature tags before giving up. */
|
|
404
|
+
const HEAL_WS_MAX = 32;
|
|
405
|
+
/** Max key length considered plausible for an inlined `<arg_key>…</arg_key>` pair. */
|
|
406
|
+
const HEAL_KEY_MAX = 128;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Result of scanning a streaming `<arg_value>` body for a forgotten or
|
|
410
|
+
* mistyped `</arg_value>` closer.
|
|
411
|
+
*
|
|
412
|
+
* - `heal`: a repair signature starts inside the value; the value ends at
|
|
413
|
+
* `valueEnd` and parsing resumes at `resumeAt` in "body" state.
|
|
414
|
+
* `trimValue` marks boundaries inferred from separator formatting, whose
|
|
415
|
+
* trailing whitespace belongs to the syntax, not the value.
|
|
416
|
+
* - `partial`: a signature may be forming at `start` but the buffer ends
|
|
417
|
+
* before it can be confirmed; the caller must hold `start..` back from
|
|
418
|
+
* streaming.
|
|
419
|
+
*/
|
|
420
|
+
type ValueHealScan =
|
|
421
|
+
| { kind: "none" }
|
|
422
|
+
| { kind: "partial"; start: number }
|
|
423
|
+
| { kind: "heal"; valueEnd: number; resumeAt: number; trimValue: boolean };
|
|
424
|
+
|
|
425
|
+
type HealFollow = { kind: "match"; resumeAt: number } | { kind: "partial" } | { kind: "none" };
|
|
426
|
+
|
|
427
|
+
type TagPrefixMatch = "match" | "partial" | "none";
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Finds the earliest heal signature starting before `limit` (the legit
|
|
431
|
+
* `</arg_value>` close, or end of buffer when absent). Two signatures repair
|
|
432
|
+
* a value whose closer the model botched:
|
|
433
|
+
*
|
|
434
|
+
* - Wrong closer: `</arg_key>` followed by `<arg_key>`, `</tool_call>`, or
|
|
435
|
+
* `</arg_value>` — the model closed the value with the wrong tag.
|
|
436
|
+
* - Missing closer: a complete `<arg_key>…</arg_key>` + `<arg_value>`
|
|
437
|
+
* sequence — the model started the next pair without closing the value.
|
|
438
|
+
*
|
|
439
|
+
* Without repair, either mistake swallows every following pair into the
|
|
440
|
+
* current value until the next `</arg_value>` anywhere in the stream.
|
|
441
|
+
*/
|
|
442
|
+
function scanValueHeal(text: string, limit: number): ValueHealScan {
|
|
443
|
+
for (let at = text.indexOf("<"); at !== -1 && at < limit; at = text.indexOf("<", at + 1)) {
|
|
444
|
+
const scan = matchHealSignature(text, at);
|
|
445
|
+
if (scan.kind !== "none") return scan;
|
|
446
|
+
}
|
|
447
|
+
return { kind: "none" };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function matchHealSignature(text: string, start: number): ValueHealScan {
|
|
451
|
+
const wrongCloser = matchTagPrefix(text, start, ARG_KEY_CLOSE);
|
|
452
|
+
if (wrongCloser === "partial") return { kind: "partial", start };
|
|
453
|
+
if (wrongCloser === "match") {
|
|
454
|
+
const follow = matchHealFollow(text, start + ARG_KEY_CLOSE.length);
|
|
455
|
+
if (follow.kind === "partial") return { kind: "partial", start };
|
|
456
|
+
if (follow.kind === "match")
|
|
457
|
+
return { kind: "heal", valueEnd: start, resumeAt: follow.resumeAt, trimValue: false };
|
|
458
|
+
return { kind: "none" };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const nextKey = matchTagPrefix(text, start, ARG_KEY_OPEN);
|
|
462
|
+
if (nextKey === "partial") return { kind: "partial", start };
|
|
463
|
+
if (nextKey === "none") return { kind: "none" };
|
|
464
|
+
let at = start + ARG_KEY_OPEN.length;
|
|
465
|
+
const keyEnd = Math.min(text.length, at + HEAL_KEY_MAX);
|
|
466
|
+
while (at < keyEnd && text[at] !== "<" && text[at] !== "\n") at++;
|
|
467
|
+
if (at === text.length) return { kind: "partial", start };
|
|
468
|
+
if (text[at] !== "<") return { kind: "none" };
|
|
469
|
+
const keyClose = matchTagPrefix(text, at, ARG_KEY_CLOSE);
|
|
470
|
+
if (keyClose === "partial") return { kind: "partial", start };
|
|
471
|
+
if (keyClose === "none") return { kind: "none" };
|
|
472
|
+
at = skipHealWhitespace(text, at + ARG_KEY_CLOSE.length);
|
|
473
|
+
if (at === -1) return { kind: "none" };
|
|
474
|
+
if (at === text.length) return { kind: "partial", start };
|
|
475
|
+
const value = matchTagPrefix(text, at, ARG_VALUE_OPEN);
|
|
476
|
+
if (value === "partial") return { kind: "partial", start };
|
|
477
|
+
if (value === "none") return { kind: "none" };
|
|
478
|
+
return { kind: "heal", valueEnd: start, resumeAt: start, trimValue: true };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Matches the tag expected after a wrong `</arg_key>` closer. */
|
|
482
|
+
function matchHealFollow(text: string, from: number): HealFollow {
|
|
483
|
+
const at = skipHealWhitespace(text, from);
|
|
484
|
+
if (at === -1) return { kind: "none" };
|
|
485
|
+
if (at === text.length) return { kind: "partial" };
|
|
486
|
+
for (const tag of [ARG_KEY_OPEN, TOOL_CLOSE]) {
|
|
487
|
+
const match = matchTagPrefix(text, at, tag);
|
|
488
|
+
if (match === "match") return { kind: "match", resumeAt: at };
|
|
489
|
+
if (match === "partial") return { kind: "partial" };
|
|
490
|
+
}
|
|
491
|
+
const close = matchTagPrefix(text, at, ARG_VALUE_CLOSE);
|
|
492
|
+
if (close === "match") return { kind: "match", resumeAt: at + ARG_VALUE_CLOSE.length };
|
|
493
|
+
if (close === "partial") return { kind: "partial" };
|
|
494
|
+
return { kind: "none" };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Skips whitespace from `from`; -1 when the run exceeds {@link HEAL_WS_MAX}. */
|
|
498
|
+
function skipHealWhitespace(text: string, from: number): number {
|
|
499
|
+
let at = from;
|
|
500
|
+
while (at < text.length && " \n\t\r".includes(text[at]!)) {
|
|
501
|
+
at++;
|
|
502
|
+
if (at - from > HEAL_WS_MAX) return -1;
|
|
503
|
+
}
|
|
504
|
+
return at;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function matchTagPrefix(text: string, at: number, tag: string): TagPrefixMatch {
|
|
508
|
+
const available = Math.min(text.length - at, tag.length);
|
|
509
|
+
for (let k = 0; k < available; k++) {
|
|
510
|
+
if (text.charCodeAt(at + k) !== tag.charCodeAt(k)) return "none";
|
|
511
|
+
}
|
|
512
|
+
return available === tag.length ? "match" : "partial";
|
|
513
|
+
}
|
|
514
|
+
|
|
392
515
|
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
|
393
516
|
return glmInvocation(call, buildArgShapes(options.tools).get(call.name));
|
|
394
517
|
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -1659,6 +1659,161 @@ function validateContext(ctx: ValidationContext, value: unknown): ContextValidat
|
|
|
1659
1659
|
};
|
|
1660
1660
|
}
|
|
1661
1661
|
|
|
1662
|
+
// In-band `arg_key`/`arg_value` tool-call syntax that leaks into native
|
|
1663
|
+
// tool-call arguments when a provider parses the model's owned format
|
|
1664
|
+
// server-side and the model botches an `</arg_value>` closer.
|
|
1665
|
+
const SPILL_KEY_OPEN = "<arg_key>";
|
|
1666
|
+
const SPILL_KEY_CLOSE = "</arg_key>";
|
|
1667
|
+
const SPILL_VALUE_OPEN = "<arg_value>";
|
|
1668
|
+
const SPILL_VALUE_CLOSE = "</arg_value>";
|
|
1669
|
+
const SPILL_TOOL_CLOSE = "</tool_call>";
|
|
1670
|
+
/** Plausible spilled argument names; anything else is ordinary content. */
|
|
1671
|
+
const SPILL_KEY_PATTERN = /^[\w.$-]{1,128}$/;
|
|
1672
|
+
|
|
1673
|
+
interface SpillSplit {
|
|
1674
|
+
head: string;
|
|
1675
|
+
pairs: [string, string][];
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function skipSpillWhitespace(text: string, from: number): number {
|
|
1679
|
+
let at = from;
|
|
1680
|
+
while (at < text.length && " \n\t\r".includes(text[at]!)) at++;
|
|
1681
|
+
return at;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** Whether a well-formed `<arg_key>NAME</arg_key>…<arg_value>` pair starts at `at`. */
|
|
1685
|
+
function isSpillPairStart(text: string, at: number): boolean {
|
|
1686
|
+
if (!text.startsWith(SPILL_KEY_OPEN, at)) return false;
|
|
1687
|
+
const keyStart = at + SPILL_KEY_OPEN.length;
|
|
1688
|
+
const keyEnd = text.indexOf(SPILL_KEY_CLOSE, keyStart);
|
|
1689
|
+
if (keyEnd === -1 || !SPILL_KEY_PATTERN.test(text.slice(keyStart, keyEnd))) return false;
|
|
1690
|
+
const valueAt = skipSpillWhitespace(text, keyEnd + SPILL_KEY_CLOSE.length);
|
|
1691
|
+
return text.startsWith(SPILL_VALUE_OPEN, valueAt);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Finds where a spilled `<arg_value>` body ends: the legit closer, a
|
|
1696
|
+
* mistyped `</arg_key>` closer (validated by its follow-up), the start of the
|
|
1697
|
+
* next pair when the closer is missing entirely, or end of input (the
|
|
1698
|
+
* provider's parser consumed the terminating closer).
|
|
1699
|
+
*/
|
|
1700
|
+
function findSpillValueEnd(text: string, from: number): { end: number; next: number } {
|
|
1701
|
+
const close = text.indexOf(SPILL_VALUE_CLOSE, from);
|
|
1702
|
+
let wrong = text.indexOf(SPILL_KEY_CLOSE, from);
|
|
1703
|
+
let open = text.indexOf(SPILL_KEY_OPEN, from);
|
|
1704
|
+
while (true) {
|
|
1705
|
+
const candidates = [close, wrong, open].filter(index => index !== -1);
|
|
1706
|
+
if (candidates.length === 0) return { end: text.length, next: text.length };
|
|
1707
|
+
const at = Math.min(...candidates);
|
|
1708
|
+
if (at === close) return { end: at, next: at + SPILL_VALUE_CLOSE.length };
|
|
1709
|
+
if (at === wrong) {
|
|
1710
|
+
const follow = skipSpillWhitespace(text, at + SPILL_KEY_CLOSE.length);
|
|
1711
|
+
if (
|
|
1712
|
+
follow >= text.length ||
|
|
1713
|
+
text.startsWith(SPILL_KEY_OPEN, follow) ||
|
|
1714
|
+
text.startsWith(SPILL_TOOL_CLOSE, follow)
|
|
1715
|
+
) {
|
|
1716
|
+
return { end: at, next: at + SPILL_KEY_CLOSE.length };
|
|
1717
|
+
}
|
|
1718
|
+
wrong = text.indexOf(SPILL_KEY_CLOSE, at + 1);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
if (isSpillPairStart(text, at)) {
|
|
1722
|
+
let end = at;
|
|
1723
|
+
while (end > from && " \n\t\r".includes(text[end - 1]!)) end--;
|
|
1724
|
+
return { end, next: at };
|
|
1725
|
+
}
|
|
1726
|
+
open = text.indexOf(SPILL_KEY_OPEN, at + 1);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Strictly parses a spill tail as `<arg_key>…</arg_key><arg_value>…` pairs,
|
|
1732
|
+
* tolerating a trailing `</tool_call>`. Returns null on any shape that is not
|
|
1733
|
+
* pure pair syntax — the caller then treats the text as ordinary content.
|
|
1734
|
+
*/
|
|
1735
|
+
function parseSpilledPairs(text: string): [string, string][] | null {
|
|
1736
|
+
const pairs: [string, string][] = [];
|
|
1737
|
+
let at = skipSpillWhitespace(text, 0);
|
|
1738
|
+
while (at < text.length) {
|
|
1739
|
+
if (text.startsWith(SPILL_TOOL_CLOSE, at)) {
|
|
1740
|
+
at = skipSpillWhitespace(text, at + SPILL_TOOL_CLOSE.length);
|
|
1741
|
+
return at >= text.length ? pairs : null;
|
|
1742
|
+
}
|
|
1743
|
+
if (!text.startsWith(SPILL_KEY_OPEN, at)) return null;
|
|
1744
|
+
const keyStart = at + SPILL_KEY_OPEN.length;
|
|
1745
|
+
const keyEnd = text.indexOf(SPILL_KEY_CLOSE, keyStart);
|
|
1746
|
+
if (keyEnd === -1) return null;
|
|
1747
|
+
const key = text.slice(keyStart, keyEnd);
|
|
1748
|
+
if (!SPILL_KEY_PATTERN.test(key)) return null;
|
|
1749
|
+
at = skipSpillWhitespace(text, keyEnd + SPILL_KEY_CLOSE.length);
|
|
1750
|
+
if (!text.startsWith(SPILL_VALUE_OPEN, at)) return null;
|
|
1751
|
+
at += SPILL_VALUE_OPEN.length;
|
|
1752
|
+
const { end, next } = findSpillValueEnd(text, at);
|
|
1753
|
+
pairs.push([key, text.slice(at, end)]);
|
|
1754
|
+
at = skipSpillWhitespace(text, next);
|
|
1755
|
+
}
|
|
1756
|
+
return pairs;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Splits a contaminated string value at the earliest spill boundary: a
|
|
1761
|
+
* mistyped `</arg_key>` closer or an inlined next pair. Returns null when no
|
|
1762
|
+
* boundary yields a cleanly parseable tail.
|
|
1763
|
+
*/
|
|
1764
|
+
function splitSpilledValue(text: string): SpillSplit | null {
|
|
1765
|
+
let wrong = text.indexOf(SPILL_KEY_CLOSE);
|
|
1766
|
+
let open = text.indexOf(SPILL_KEY_OPEN);
|
|
1767
|
+
while (wrong !== -1 || open !== -1) {
|
|
1768
|
+
if (wrong !== -1 && (open === -1 || wrong < open)) {
|
|
1769
|
+
const pairs = parseSpilledPairs(text.slice(wrong + SPILL_KEY_CLOSE.length));
|
|
1770
|
+
if (pairs) return { head: text.slice(0, wrong), pairs };
|
|
1771
|
+
wrong = text.indexOf(SPILL_KEY_CLOSE, wrong + 1);
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (isSpillPairStart(text, open)) {
|
|
1775
|
+
const pairs = parseSpilledPairs(text.slice(open));
|
|
1776
|
+
if (pairs && pairs.length > 0) return { head: text.slice(0, open).trimEnd(), pairs };
|
|
1777
|
+
}
|
|
1778
|
+
open = text.indexOf(SPILL_KEY_OPEN, open + 1);
|
|
1779
|
+
}
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Repairs native tool-call arguments contaminated by in-band
|
|
1785
|
+
* `<arg_key>`/`<arg_value>` syntax. Some providers parse owned tool-call
|
|
1786
|
+
* formats server-side; when the model mistypes or omits an `</arg_value>`
|
|
1787
|
+
* closer, every following pair is swallowed into one string argument, e.g.
|
|
1788
|
+
* `op: "done</arg_key>\n<arg_key>task</arg_key>\n<arg_value>…"`. Truncates
|
|
1789
|
+
* each contaminated top-level string at its spill boundary and restores the
|
|
1790
|
+
* swallowed pairs as sibling arguments (never overwriting existing keys).
|
|
1791
|
+
*
|
|
1792
|
+
* Only invoked after validation and every coercion pass fail, so valid calls
|
|
1793
|
+
* whose string content legitimately contains tag-like text are never touched.
|
|
1794
|
+
*/
|
|
1795
|
+
function healInbandArgSpill(value: unknown): { value: unknown; changed: boolean } {
|
|
1796
|
+
if (!isPlainRecord(value)) return { value, changed: false };
|
|
1797
|
+
let changed = false;
|
|
1798
|
+
const out: Record<string, unknown> = { ...value };
|
|
1799
|
+
const recovered: [string, string][] = [];
|
|
1800
|
+
for (const key in value) {
|
|
1801
|
+
const entry = value[key];
|
|
1802
|
+
if (typeof entry !== "string") continue;
|
|
1803
|
+
if (!entry.includes(SPILL_KEY_OPEN) && !entry.includes(SPILL_KEY_CLOSE)) continue;
|
|
1804
|
+
const split = splitSpilledValue(entry);
|
|
1805
|
+
if (!split) continue;
|
|
1806
|
+
out[key] = split.head;
|
|
1807
|
+
recovered.push(...split.pairs);
|
|
1808
|
+
changed = true;
|
|
1809
|
+
}
|
|
1810
|
+
if (!changed) return { value, changed: false };
|
|
1811
|
+
for (const [key, entry] of recovered) {
|
|
1812
|
+
if (!(key in out)) out[key] = entry;
|
|
1813
|
+
}
|
|
1814
|
+
return { value: out, changed: true };
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1662
1817
|
const MAX_COERCION_PASSES = 5;
|
|
1663
1818
|
|
|
1664
1819
|
/**
|
|
@@ -1786,7 +1941,67 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1786
1941
|
let result = validateContext(ctx, normalizedArgs);
|
|
1787
1942
|
if (result.success) return result.value as ToolCall["arguments"];
|
|
1788
1943
|
|
|
1944
|
+
const coercionOutcome = runCoercionPasses(ctx, normalizedArgs, result);
|
|
1945
|
+
normalizedArgs = coercionOutcome.args;
|
|
1946
|
+
changed ||= coercionOutcome.changed;
|
|
1947
|
+
result = coercionOutcome.result;
|
|
1948
|
+
if (result.success) return result.value as ToolCall["arguments"];
|
|
1949
|
+
|
|
1950
|
+
// Last resort: some providers parse in-band tool-call syntax server-side,
|
|
1951
|
+
// and a mistyped/missing `</arg_value>` closer inlines the remaining pairs
|
|
1952
|
+
// into one string argument. Gated on validation failure so valid calls
|
|
1953
|
+
// with tag-like string content are never rewritten.
|
|
1954
|
+
const spillHeal = healInbandArgSpill(normalizedArgs);
|
|
1955
|
+
if (spillHeal.changed) {
|
|
1956
|
+
normalizedArgs = spillHeal.value;
|
|
1957
|
+
changed = true;
|
|
1958
|
+
result = validateContext(ctx, normalizedArgs);
|
|
1959
|
+
if (!result.success) {
|
|
1960
|
+
const healedOutcome = runCoercionPasses(ctx, normalizedArgs, result);
|
|
1961
|
+
normalizedArgs = healedOutcome.args;
|
|
1962
|
+
result = healedOutcome.result;
|
|
1963
|
+
}
|
|
1964
|
+
if (result.success) return result.value as ToolCall["arguments"];
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Format validation errors nicely. The header phrase is asserted by
|
|
1968
|
+
// existing tests; the detailed body is informational.
|
|
1969
|
+
const errors = result.messages.join("\n") || "Unknown validation error";
|
|
1970
|
+
|
|
1971
|
+
// Truncate long per-field strings: the full payload (potentially hundreds
|
|
1972
|
+
// of KB for write/edit-class calls) would otherwise round-trip back to the
|
|
1973
|
+
// model inside the tool error.
|
|
1974
|
+
const receivedArgs = changed
|
|
1975
|
+
? {
|
|
1976
|
+
original: truncateArgsForError(originalArgs),
|
|
1977
|
+
normalized: truncateArgsForError(normalizedArgs),
|
|
1978
|
+
}
|
|
1979
|
+
: truncateArgsForError(originalArgs);
|
|
1980
|
+
|
|
1981
|
+
const errorMessage = `Validation failed for tool "${
|
|
1982
|
+
toolCall.name
|
|
1983
|
+
}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
|
|
1984
|
+
|
|
1985
|
+
throw new AIError.ValidationError(errorMessage);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Runs up to {@link MAX_COERCION_PASSES} issue-driven coercion rounds,
|
|
1990
|
+
* re-applying the schema normalizations after each round because a coercion
|
|
1991
|
+
* may unwrap JSON-string containers and expose fields the pre-validation
|
|
1992
|
+
* passes could not reach.
|
|
1993
|
+
*/
|
|
1994
|
+
function runCoercionPasses(
|
|
1995
|
+
ctx: ValidationContext,
|
|
1996
|
+
args: unknown,
|
|
1997
|
+
initial: ContextValidationResult,
|
|
1998
|
+
): { args: unknown; result: ContextValidationResult; changed: boolean } {
|
|
1999
|
+
const { json } = ctx;
|
|
2000
|
+
let normalizedArgs = args;
|
|
2001
|
+
let result = initial;
|
|
2002
|
+
let changed = false;
|
|
1789
2003
|
for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) {
|
|
2004
|
+
if (result.success) break;
|
|
1790
2005
|
const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues);
|
|
1791
2006
|
if (!coercion.changed) break;
|
|
1792
2007
|
|
|
@@ -1840,26 +2055,6 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1840
2055
|
}
|
|
1841
2056
|
|
|
1842
2057
|
result = validateContext(ctx, normalizedArgs);
|
|
1843
|
-
if (result.success) return result.value as ToolCall["arguments"];
|
|
1844
2058
|
}
|
|
1845
|
-
|
|
1846
|
-
// Format validation errors nicely. The header phrase is asserted by
|
|
1847
|
-
// existing tests; the detailed body is informational.
|
|
1848
|
-
const errors = result.messages.join("\n") || "Unknown validation error";
|
|
1849
|
-
|
|
1850
|
-
// Truncate long per-field strings: the full payload (potentially hundreds
|
|
1851
|
-
// of KB for write/edit-class calls) would otherwise round-trip back to the
|
|
1852
|
-
// model inside the tool error.
|
|
1853
|
-
const receivedArgs = changed
|
|
1854
|
-
? {
|
|
1855
|
-
original: truncateArgsForError(originalArgs),
|
|
1856
|
-
normalized: truncateArgsForError(normalizedArgs),
|
|
1857
|
-
}
|
|
1858
|
-
: truncateArgsForError(originalArgs);
|
|
1859
|
-
|
|
1860
|
-
const errorMessage = `Validation failed for tool "${
|
|
1861
|
-
toolCall.name
|
|
1862
|
-
}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
|
|
1863
|
-
|
|
1864
|
-
throw new AIError.ValidationError(errorMessage);
|
|
2059
|
+
return { args: normalizedArgs, result, changed };
|
|
1865
2060
|
}
|