@oh-my-pi/pi-ai 16.4.3 → 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 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.3",
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.3",
42
- "@oh-my-pi/pi-utils": "16.4.3",
43
- "@oh-my-pi/pi-wire": "16.4.3",
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
  },
@@ -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 hold = final ? 0 : partialSuffixOverlap(this.#buffer, ARG_VALUE_CLOSE);
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
  }
@@ -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
  }