@juno-ai/bind 12.0.0 → 14.0.0

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.
@@ -32,13 +32,64 @@
32
32
  * The transforms only remove or normalize constructs that carry no real
33
33
  * constraint for a model's tool call. Each was verified against live Gemini:
34
34
  *
35
- * 1. Collapse a `type` ARRAY to a single `type`. Gemini requires a scalar
36
- * `type` and rejects a union array. We keep the first non-`"null"`
37
- * member (e.g. `["string","number","boolean"]` → `"string"`,
38
- * `["string","null"]` `"string"`); an array of only `"null"` drops
39
- * the `type`. The lost alternatives are advisory — arguments are still
40
- * validated at dispatch against the tool's zod schema and by the remote
41
- * MCP server.
35
+ * 1. Resolve a `type` ARRAY, which Gemini rejects outright (it requires a
36
+ * scalar `type`). Two cases, and they are not the same kind of loss:
37
+ *
38
+ * a. NULLABLE single type — exactly one non-`"null"` member plus `"null"`,
39
+ * in either order, duplicates tolerated. Rewritten to
40
+ * `anyOf: [ <T branch>, {type:"null"} ]`, where the T branch carries
41
+ * the node's type-bearing keywords (`minLength`, `enum`, `properties`,
42
+ * `items`, bounds, …) under `type: T` and the node keeps its
43
+ * annotations. The branch is re-entered through this same walk, so
44
+ * every per-node transform applies to it — #12 retypes it to `"object"`
45
+ * when it carries `properties`, #13 drops an unsupported `pattern` into
46
+ * prose ON the branch, #2 filters its `enum`, #3 resolves its
47
+ * `required` against its own `properties`.
48
+ *
49
+ * Collapsing this case instead was lossy in a way that broke callers in
50
+ * production. A model handed `{type:"string", minLength:1,
51
+ * description:"… or send null …"}` cannot express "none" in the half of
52
+ * the declaration it treats as binding, so it INVENTS a value that
53
+ * satisfies the type: `slack_send_message.thread_ts` (authored
54
+ * `type:["string","null"], minLength:1`) received `"/"`, `". "`,
55
+ * `".000001"` and the like for thousands of calls, each refused by
56
+ * Slack and each retried; a Drive pagination cursor had already been
57
+ * seen taking `"x"`, `" "`, `"undefined"`, `"null"` and `"/"`. Prose
58
+ * loses to the schema every time. The `anyOf` shape is not a guess: it
59
+ * passes under `properties` on grok-4.3/4.5/4.6,
60
+ * gemini-3.5/3.6/3.7-flash and gpt-5.6-terra/sol/luna, and it ships
61
+ * today on every pagination cursor of six connectors. No "Accepts T or
62
+ * null." note is added — the schema now says it — and an `enum` that
63
+ * listed `null` contributes only its non-null members to the branch,
64
+ * with no "May also be null." note, since the null branch carries that.
65
+ * A sibling `enum`/`const` that EXCLUDES one arm keeps case (b)
66
+ * instead: those keywords are AND-ed with `type`, so
67
+ * `{type:["string","null"], enum:["x"]}` means "must be \"x\"", and a
68
+ * union whose null branch the enum never reaches would admit a value
69
+ * the node forbade.
70
+ *
71
+ * Four shapes opt out. The parameters ROOT, which has to be an object
72
+ * for every provider (transform #9 owns it). A branch of a ROOT
73
+ * `anyOf`/`oneOf`, because a root union branch carrying its OWN `anyOf`
74
+ * is 0/3 on Grok — rewriting there would turn a usable branch into one
75
+ * rule (a) drops, taking the whole keyword and every shape it
76
+ * advertised with it. And a COMPOSITION BRANCH whose `required` reaches
77
+ * the ENCLOSING node's `properties` — the extra scope transform #8
78
+ * gives it — because nesting such a name inside an `anyOf` puts it two
79
+ * levels from that scope, where transform #3 prunes it away entirely
80
+ * (a branch whose `required` its own `properties` cover needs no
81
+ * exception). All three keep case (b). (One residue is known and accepted: a `$defs` target
82
+ * rewritten this way, then inlined into a root union branch by
83
+ * transform #14, is unusable for the same 0/3 reason and still drops
84
+ * the keyword — the same narrow class #14 already documents.)
85
+ *
86
+ * b. Everything else — a genuine MULTI-type union, which has no shape
87
+ * every provider accepts. Collapsed to the first non-`"null"` member
88
+ * (`["string","number","boolean"]` → `"string"`) with the alternatives
89
+ * folded into `description`; an array of only `"null"` drops the
90
+ * `type`. The lost alternatives are advisory — arguments are still
91
+ * validated at dispatch against the tool's zod schema and by the remote
92
+ * MCP server.
42
93
  * 2. Constrain `enum` to Gemini's rule: it is accepted ONLY as a list of
43
94
  * strings on a string-typed (or type-less) property. So drop `enum`
44
95
  * entirely when the node has an explicit non-string type (boolean,
@@ -174,8 +225,11 @@
174
225
  * `type: "object"` to such a root makes exactly those cases pass 9/9
175
226
  * without touching the composition — so the un-flattenable root `allOf`
176
227
  * that transform #5 deliberately preserves is RESCUED rather than
177
- * discarded, and it stops being the silent 400 it is today. A `type` the
178
- * schema declared for itself is never overridden.
228
+ * discarded, and it stops being the silent 400 it is today. A non-object
229
+ * `type` the root declared for itself IS rewritten to `"object"` and
230
+ * recorded in the description — measured, such a root is rejected by
231
+ * every model. Nested nodes keep their declared types unless transform
232
+ * #12 applies.
179
233
  *
180
234
  * Rule (a) is all-or-nothing per keyword, because ONE unusable branch
181
235
  * poisons the whole union on xAI even when its siblings are good (0/3 with a
@@ -187,12 +241,36 @@
187
241
  * callable and a wrong COMBINATION comes back from dispatch/the remote
188
242
  * server as a recoverable tool error.
189
243
  *
190
- * Two shapes are known losses, both measured and both unavoidable here:
191
- * a root union of `$ref` branches (xAI does not resolve them 0/3 even with
192
- * an object root), and a root union whose branch is itself a composition
193
- * (`{oneOf:[{anyOf:[…]}]}` 0/3 even with `type:"object"` stamped on the
194
- * root). Both drop to a root that is accepted but advertises no properties.
195
- * Recovering the first means inlining same-document `$ref`s before rule (a).
244
+ * A branch's OWN `anyOf`/`oneOf` disqualifies it too, whatever else it
245
+ * declares: `{type:"object", anyOf:[…]}` as a root union branch is 0/3 on
246
+ * Grok, as is a branch carrying both `allOf` and `anyOf` the union is what
247
+ * poisons it. Scope is the branch's own keys and never its subtree: a
248
+ * composition on a branch's PROPERTY is accepted 9/9, one or two levels
249
+ * down, so a subtree scan would destroy constraints every provider honours.
250
+ * A branch's own `allOf` is likewise accepted 9/9 (even with a
251
+ * bare-`required` sub-branch), the same intersection-vs-union asymmetry xAI
252
+ * applies to the root — hence `ROOT_UNION_KEYS`, not `COMPOSITION_KEYS`.
253
+ *
254
+ * A root union of `$ref` branches is no longer a loss — transform #14 below
255
+ * inlines the targets so the union survives. What remains is the narrow
256
+ * residue it cannot inline: a target that is not a plain object schema
257
+ * (a union of its own included, which is what a `$defs` entry transform
258
+ * #1a rewrote becomes), one whose expanded size exceeds the copy-down
259
+ * ceiling, and a self-referential root pointer. Those branches stay
260
+ * unusable and still drop the keyword.
261
+ *
262
+ * Rule (b) covers EVERY root, not only composition roots. A property sweep
263
+ * over recursive shapes (`__tests__/tool-schema/property/`) found the
264
+ * narrower gate emitting unusable roots for whole classes the fixtures
265
+ * never reached: a scalar or array root (reachable from transform #1
266
+ * collapsing `{type:["string","null"]}`), an annotation-only root, a
267
+ * `$defs`-only root, and a root whose `type` contradicts its `properties` —
268
+ * that last one rejected by all nine models, the only rule here with no
269
+ * tolerant provider. Rule (b) also DROPS a `const` or `$ref` carried by the
270
+ * root: measured `root schema is a const` / `root schema is a $ref`, 400
271
+ * even alongside `type: "object"` and `properties`, and for `$ref` even
272
+ * when the target resolves. Nested `const`/`$ref` are meaningful and
273
+ * untouched.
196
274
  *
197
275
  * A root `allOf`'s BRANCHES are deliberately not constrained by (a):
198
276
  * measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar
@@ -201,11 +279,78 @@
201
279
  * all object-carrying is accepted too (xAI infers object-ness from them), so
202
280
  * (b) leaves it alone. Compositions nested below the root are untouched.
203
281
  *
204
- * When (1) or (2) discards information the model could use — a collapsed
205
- * union type, or a wholly-dropped `enum` that constraint is folded into the
206
- * node's `description` as prose ("Accepts string, number, or boolean.",
282
+ * 10. Drop an EMPTY array-valued keyword (`allOf`/`anyOf`/`oneOf`/`prefixItems`)
283
+ * at any depth. xAI rejects one anywhere in the tree `/properties/a/oneOf:
284
+ * [] has less than 1 item` and it carries no constraint (an `allOf` of
285
+ * nothing is satisfied by everything). Empty OBJECTS (`properties: {}`,
286
+ * `$defs: {}`) and an empty `required: []` are accepted and left alone;
287
+ * `enum: []` and `required: []` were already omitted by transforms #2/#3.
288
+ * 11. Drop a `$ref` whose LOCAL target does not resolve. xAI resolves `#/…`
289
+ * pointers itself and 400s the whole request on a dangling one
290
+ * (`unresolvable $ref '#/$defs/Nope'`), which a third-party MCP server
291
+ * produces easily by shipping a subschema without its definitions — or by
292
+ * putting `$defs` on a nested node, since `#/$defs/X` is anchored at the
293
+ * document ROOT and a nested `$defs` is unaddressable that way. External
294
+ * refs (`https://…`) and resolvable non-`$defs` pointers (`#/properties/a`)
295
+ * are both accepted by xAI and left alone. Only the `$ref` keyword is
296
+ * dropped, so the rest of the node survives.
297
+ *
298
+ * 12. Give any node carrying `properties` an explicit `type: "object"`. Gemini
299
+ * rejects the type-less form outright — "Unable to submit request because
300
+ * `t` functionDeclaration `parameters.b` schema specified incorrect schema
301
+ * type field. For schema with properties, schema type should be OBJECT" —
302
+ * on gemini-3.5/3.6/3.7-flash, and rejects a CONTRADICTORY declared type
303
+ * (`{type:"string", properties:{…}}`) the same way, so the type is
304
+ * overwritten rather than merely defaulted. It is not composition-specific:
305
+ * a plain property subschema and an `items` schema fail identically. xAI
306
+ * and OpenAI accept every one of those forms, which is why this survived
307
+ * until a property sweep over recursive shapes went looking for it. The
308
+ * root is exempt in Gemini's own validator, but the rule is applied
309
+ * uniformly because it costs nothing and one rule beats two.
310
+ *
311
+ * It is resolved during the node's type resolution, not patched onto the
312
+ * finished node, so `dropEnum` sees the real type — an `enum` left on a
313
+ * node that has just become an object would otherwise be dropped by the
314
+ * NEXT pass instead of this one, breaking idempotence. A discarded
315
+ * contradictory type is folded into the description like transform #1's.
316
+ * This also makes a contradictory root-union BRANCH repairable: it arrives
317
+ * at transform #9 already retyped, so the constraint survives instead of
318
+ * the whole keyword being dropped.
319
+ *
320
+ * 13. Drop a `pattern` that uses a regex construct a provider's validator
321
+ * refuses, echoing it into the description instead. Measured: `(?=`, `(?!`,
322
+ * `(?<=`, `(?<!` AND named groups `(?<name>` are rejected by gpt-5.6-sol
323
+ * and gpt-5.6-terra (`Invalid JSON schema: regex lookaround is not
324
+ * supported` — the message says lookaround, but a named group fails
325
+ * identically, so the giveaway is the `(?<` prefix); a BACKREFERENCE
326
+ * (`\1`–`\9`) is rejected by grok-4.5. Non-capturing `(?:` is accepted
327
+ * everywhere and is deliberately NOT caught. This is the defect that broke
328
+ * Monad's own `spaces__invite_member` on every OpenAI model — a whole-
329
+ * request 400 from one tool's email pattern. The scan tracks backslash
330
+ * escaping and character classes, so a literal `\(\?=` or a `[(?=]` class
331
+ * keeps its pattern: dropping a pattern that would have been accepted
332
+ * costs a real constraint on every provider.
333
+ * 14. Inline a ROOT union branch that is a local `$ref`, so the union survives
334
+ * rule (a) instead of being dropped whole. `z.union([A, B])` renders as
335
+ * `{anyOf:[{$ref},{$ref}], $defs}` and xAI does not resolve a `$ref` union
336
+ * branch (0/3 even with `type: "object"` on the root) — so rule (a) used to
337
+ * drop the keyword, which for a root whose properties live entirely in
338
+ * `$defs` left the tool advertising NO parameters. Inlining produces
339
+ * ordinary object branches, accepted 9/9. Only branches that are not
340
+ * already usable are inlined, one level deep (a `$ref` inside the target
341
+ * resolves on its own, which is what makes a recursive definition
342
+ * terminate here), with branch keywords winning over the target's per
343
+ * 2020-12 `$ref` semantics and a cap for the same reason the copy-down has
344
+ * one. A branch whose target is not a plain object schema stays unusable
345
+ * and still falls through to the drop.
346
+ *
347
+ * When (1b) or (2) discards information the model could use — a collapsed
348
+ * multi-type union, or a wholly-dropped `enum` — that constraint is folded into
349
+ * the node's `description` as prose ("Accepts string, number, or boolean.",
207
350
  * "Allowed values: true.") so the model still sees it. `description` is a free
208
- * string every provider accepts, so this is always safe.
351
+ * string every provider accepts, so this is always safe. Prose is the fallback,
352
+ * never the first choice: where a shape every provider accepts exists, the
353
+ * schema says it instead — which is the whole of (1a).
209
354
  *
210
355
  * It deliberately leaves meaningful validation keywords (`format`,
211
356
  * `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
@@ -280,6 +425,93 @@ const SUBSCHEMA_ARRAY_KEYS = new Set([
280
425
  * `prefixItems` is in `SUBSCHEMA_ARRAY_KEYS` but NOT here: its elements are the
281
426
  * item schemas of a tuple, a fresh scope, not branches of the enclosing object. */
282
427
  const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
428
+ /**
429
+ * Transform #1, nullable case: the JSON Schema type names that may stand
430
+ * opposite `"null"` in a `type` array for the `anyOf` rewrite to fire. An
431
+ * unrecognized name is left to the collapse — rewriting `{type:["foo","null"]}`
432
+ * into a two-branch union would emit an unmeasured shape built around a type no
433
+ * provider knows, where the collapse at least degrades to prose.
434
+ */
435
+ const NULLABLE_UNION_TYPES = new Set([
436
+ "string",
437
+ "number",
438
+ "integer",
439
+ "boolean",
440
+ "object",
441
+ "array",
442
+ ]);
443
+ /**
444
+ * Keywords whose presence on a node disqualifies it from the nullable rewrite.
445
+ * Each is a construct the emitted `anyOf` would have to be evaluated ALONGSIDE
446
+ * (an `anyOf` sibling to an `oneOf`, an `if`/`then` keyed off the node's type,
447
+ * …), and no such combination has been measured against a provider. A node
448
+ * carrying one keeps transform #1's collapse.
449
+ */
450
+ const NULLABLE_REWRITE_BLOCKERS = [
451
+ "anyOf",
452
+ "oneOf",
453
+ "allOf",
454
+ "not",
455
+ "if",
456
+ "then",
457
+ "else",
458
+ ];
459
+ /**
460
+ * The type-bearing keywords that move from a nullable node onto its typed
461
+ * branch, where they constrain the non-null value they were written for. Every
462
+ * one of them is meaningless against `null`, so leaving them on the outer node
463
+ * beside the union would say something the author never meant.
464
+ *
465
+ * The object-structure keywords move as a GROUP, and that is load-bearing
466
+ * rather than tidy. `additionalProperties` is defined against the keys that
467
+ * `properties` and `patternProperties` did NOT match *in the same schema
468
+ * object*, so moving two of the three and stranding the third silently changes
469
+ * what the schema says: for `{properties:{a}, patternProperties:{"^x":…},
470
+ * additionalProperties:{…}}`, a key `x1` was exempt from
471
+ * `additionalProperties` and, split across the union boundary, stops being
472
+ * exempt. `dependencies` / `dependentRequired` / `dependentSchemas` travel with
473
+ * them because they describe the same object form.
474
+ *
475
+ * Two groups deliberately stay OUTSIDE. Annotations (`description`, `title`,
476
+ * `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `$comment`)
477
+ * describe the property as a whole, including its null case, and duplicating a
478
+ * description into the branch would show the model the same sentence twice. And
479
+ * `unevaluatedProperties` / `unevaluatedItems` are defined over the annotations
480
+ * every applicable schema produced — the `anyOf` branches included — so the
481
+ * outer node is exactly where they belong; moving one inside would narrow it to
482
+ * the branch's own keywords. Anything else the node carries (`$defs`, `$id`, an
483
+ * unknown keyword) stays outside too, keeping the treatment it has today.
484
+ */
485
+ const NULLABLE_BRANCH_KEYS = new Set([
486
+ "minLength",
487
+ "maxLength",
488
+ "pattern",
489
+ "format",
490
+ "enum",
491
+ "const",
492
+ "minimum",
493
+ "maximum",
494
+ "exclusiveMinimum",
495
+ "exclusiveMaximum",
496
+ "multipleOf",
497
+ "items",
498
+ "prefixItems",
499
+ "minItems",
500
+ "maxItems",
501
+ "uniqueItems",
502
+ "properties",
503
+ "patternProperties",
504
+ "required",
505
+ "additionalProperties",
506
+ "propertyNames",
507
+ "minProperties",
508
+ "maxProperties",
509
+ "dependencies",
510
+ "dependentRequired",
511
+ "dependentSchemas",
512
+ "contentMediaType",
513
+ "contentEncoding",
514
+ ]);
283
515
  /**
284
516
  * Ceiling on how many property subschemas transform #8 may copy down across ALL
285
517
  * branches of one root composition.
@@ -300,6 +532,109 @@ const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
300
532
  * branches with a handful of `required` names each).
301
533
  */
302
534
  const MAX_ROOT_COPY_DOWN = 512;
535
+ /**
536
+ * Recursive size of a subtree, in nodes and keys, stopping as soon as it exceeds
537
+ * `cap`. Used to price transform #14's inlining: counting only a target's
538
+ * TOP-LEVEL keys is no bound at all, because `{type, properties}` costs 2 no
539
+ * matter how large `properties` is — measured, that let a 0.11 MB schema with
540
+ * 256 `$ref` branches expand to 26 MB of request body, a 242x amplification on
541
+ * attacker-controlled input. Early exit keeps the pricing itself cheap and
542
+ * bounded, and the depth guard mirrors the walk's.
543
+ */
544
+ function subtreeWeight(value, cap, depth = 0) {
545
+ if (depth > MAX_DEPTH)
546
+ return cap + 1;
547
+ if (Array.isArray(value)) {
548
+ let total = 1;
549
+ for (const item of value) {
550
+ total += subtreeWeight(item, cap - total, depth + 1);
551
+ if (total > cap)
552
+ return total;
553
+ }
554
+ return total;
555
+ }
556
+ if (!isPlainObject(value))
557
+ return 1;
558
+ let total = 1;
559
+ for (const item of Object.values(value)) {
560
+ total += 1 + subtreeWeight(item, cap - total, depth + 1);
561
+ if (total > cap)
562
+ return total;
563
+ }
564
+ return total;
565
+ }
566
+ /** Longest `pattern` echoed into a description when transform #13 drops it.
567
+ * A regex is guidance at that point, and an unbounded one would crowd out the
568
+ * rest of the tool's description in the model's context. */
569
+ const MAX_PATTERN_IN_PROSE = 120;
570
+ /**
571
+ * True if a `pattern` uses a regex construct some provider's validator refuses.
572
+ * Measured, one tool per request, `provider.allow_fallbacks: false`:
573
+ *
574
+ * - `(?=`, `(?!`, `(?<=`, `(?<!` and NAMED GROUPS `(?<name>` are all rejected
575
+ * by gpt-5.6-sol and gpt-5.6-terra (`Invalid JSON schema: regex lookaround
576
+ * is not supported`), and accepted by Gemini and Grok. The error says
577
+ * "lookaround", but a named group fails identically — the giveaway is the
578
+ * `(?<` prefix, not the semantics — so all of `(?<` goes.
579
+ * - A BACKREFERENCE (`\1`…`\9`) is rejected by grok-4.5 and accepted by the
580
+ * others.
581
+ * - Non-capturing `(?:` is accepted everywhere and must not be caught here.
582
+ *
583
+ * The scan tracks backslash escaping and character classes so a LITERAL `\(?=`
584
+ * or a `[(?=]` class is not mistaken for the construct — dropping a pattern
585
+ * that would have been accepted costs a real constraint on every provider.
586
+ * Inside a class, `\1` is an octal escape rather than a backreference, so
587
+ * backreference detection is suppressed there too.
588
+ */
589
+ function usesUnsupportedRegexConstruct(pattern) {
590
+ // A deliberately NAIVE character-class scan: `[` opens, the next `]` closes.
591
+ // RFC-correct regex treats a `]` in first position as a literal (`[]a]` is the
592
+ // two-member class `]`,`a`), and an earlier revision of this function did too
593
+ // — but the providers do not, and they are what we are modelling. Measured:
594
+ // `^[(?=]+$` is accepted 5/5 (the `(?=` reads as class content under BOTH
595
+ // parses), while `^[](?=)]+$` is REJECTED by gpt-5.6-sol/terra and accepted by
596
+ // Grok and Gemini. That split is only explicable if OpenAI closes the class at
597
+ // the first `]`, leaving `(?=` outside — i.e. the naive parse. Matching the
598
+ // strict spec here would keep a pattern OpenAI 400s the whole request over.
599
+ let inClass = false;
600
+ for (let i = 0; i < pattern.length; i++) {
601
+ const char = pattern[i];
602
+ if (char === "\\") {
603
+ const next = pattern[i + 1];
604
+ if (!inClass && next !== undefined) {
605
+ // `\1`–`\9` is a backreference (`\0` is NUL), rejected by grok-4.5.
606
+ if (next >= "1" && next <= "9")
607
+ return true;
608
+ // `\k<name>` is a NAMED backreference, rejected by grok-4.5 AND
609
+ // gpt-5.6-sol.
610
+ if (next === "k" && pattern[i + 2] === "<")
611
+ return true;
612
+ }
613
+ i++; // skip the escaped character
614
+ continue;
615
+ }
616
+ if (inClass) {
617
+ if (char === "]")
618
+ inClass = false;
619
+ continue;
620
+ }
621
+ if (char === "[") {
622
+ inClass = true;
623
+ continue;
624
+ }
625
+ if (char === "(" && pattern[i + 1] === "?") {
626
+ const kind = pattern[i + 2];
627
+ // `=`/`!` lookahead and `<` (lookbehind OR named group) are rejected by
628
+ // the OpenAI models; `>` (atomic group) is rejected by grok-4.5. Inline
629
+ // flags `(?i)` and non-capturing `(?:` are accepted everywhere and must
630
+ // NOT be caught.
631
+ if (kind === "=" || kind === "!" || kind === "<" || kind === ">") {
632
+ return true;
633
+ }
634
+ }
635
+ }
636
+ return false;
637
+ }
303
638
  /** Name → subschema maps. `properties` is listed here for completeness, but a
304
639
  * dedicated `key === "properties"` branch in `sanitizeSchemaNode` intercepts it
305
640
  * first (to apply transform #7) and `continue`s — so the generic map handler
@@ -593,20 +928,154 @@ function sanitizeCompositionBranch(branch, depth, parentProperties, budget) {
593
928
  // Arrays and boolean/primitive schemas carry no `required` to resolve.
594
929
  if (!isPlainObject(branch))
595
930
  return sanitizeSubschema(branch, depth);
596
- const sanitized = sanitizeSchemaNode(branch, depth, parentProperties);
597
931
  // A non-null budget marks a ROOT composition — the only place the
598
- // self-sufficiency repair applies (see the module header).
932
+ // self-sufficiency repair applies, and the only place transform #1's nullable
933
+ // rewrite stands down (see the module header).
934
+ const sanitized = sanitizeSchemaNode(branch, depth, parentProperties, budget !== null);
599
935
  return budget
600
936
  ? makeBranchSelfSufficient(sanitized, parentProperties, budget)
601
937
  : sanitized;
602
938
  }
939
+ /**
940
+ * Transform #1, nullable case: decide whether this node is a nullable single
941
+ * type — `["string","null"]` and friends — that should be REWRITTEN to an
942
+ * `anyOf` union instead of collapsed. Returns the non-null type name, or `null`
943
+ * when the node keeps today's collapse.
944
+ *
945
+ * Order of the members does not matter and duplicates are tolerated
946
+ * (`["null","string"]`, `["string","null","null"]`); what must hold is that
947
+ * exactly one distinct non-`"null"` type is present, alongside at least one
948
+ * `"null"`.
949
+ *
950
+ * Four shapes opt out, each for a measured or structural reason:
951
+ * - The ROOT and a branch of a ROOT composition (`isRootCompositionBranch`).
952
+ * The root must be an object for every provider, and a root union branch
953
+ * carrying its OWN `anyOf` is 0/3 on Grok — so rewriting there would turn a
954
+ * usable branch into one transform #9 rule (a) drops, taking the whole
955
+ * keyword (and with it every shape the union advertised) with it.
956
+ * - A sibling `enum`/`const` that excludes one of the two arms. These are
957
+ * AND-ed with `type`, so `{type:["string","null"], enum:["x"]}` means "must
958
+ * be \"x\"" and `{…, enum:[null]}` means "must be null". The rewrite moves
959
+ * the keyword onto the TYPED branch, leaving `{type:"null"}` unconstrained
960
+ * by it — so emitting the union would admit a value the node forbade. Both
961
+ * directions keep the collapse; `const: null` is the exception that still
962
+ * rewrites, because there the typed branch is unsatisfiable and the union
963
+ * reduces to "must be null", which is what the node says (collapsing it
964
+ * would give the unsatisfiable `{type:"string", const:null}` instead).
965
+ * - A COMPOSITION BRANCH whose `required` reaches the ENCLOSING node's
966
+ * `properties` — the extra scope transform #8 gives it. Moving such a name
967
+ * inside a nested `anyOf` puts it two levels from that scope, which is
968
+ * exactly the dangling `required` transform #3 exists to prevent on Gemini.
969
+ * A branch whose `required` is covered by its OWN `properties` needs no
970
+ * exception and is rewritten normally.
971
+ */
972
+ function nullableUnionType(node, parentProperties, isRootCompositionBranch) {
973
+ if (isRootCompositionBranch)
974
+ return null;
975
+ const declared = node.type;
976
+ if (!Array.isArray(declared))
977
+ return null;
978
+ let typed = null;
979
+ let sawNull = false;
980
+ for (const member of declared) {
981
+ if (typeof member !== "string")
982
+ return null;
983
+ if (member === "null") {
984
+ sawNull = true;
985
+ continue;
986
+ }
987
+ if (!NULLABLE_UNION_TYPES.has(member))
988
+ return null;
989
+ if (typed !== null && typed !== member)
990
+ return null;
991
+ typed = member;
992
+ }
993
+ if (!sawNull || typed === null)
994
+ return null;
995
+ if (NULLABLE_REWRITE_BLOCKERS.some((key) => hasOwn(node, key)))
996
+ return null;
997
+ // A sibling `enum`/`const` is AND-ed with `type`, so it decides whether the
998
+ // two arms the `type` array advertises are BOTH actually reachable — and the
999
+ // rewrite may only emit an arm the input really allows. Moving the keyword
1000
+ // onto the typed branch leaves the `{type:"null"}` branch unconstrained by
1001
+ // it, so emitting that branch when the enum excludes `null` would admit a
1002
+ // value the author forbade (`{type:["string","null"], enum:["x"]}` means
1003
+ // "must be \"x\""), and emitting the typed branch when only `null` is allowed
1004
+ // would admit every string. Either way the union would say more than the node
1005
+ // did, so both keep the collapse.
1006
+ //
1007
+ // `const: null` is the one shape that still rewrites: the typed branch is
1008
+ // then unsatisfiable and the union reduces to "must be null", exactly what
1009
+ // the node means — where collapsing it would produce the unsatisfiable
1010
+ // `{type:"string", const:null}` instead, which is strictly worse.
1011
+ if (hasOwn(node, "const") && node.const !== null)
1012
+ return null;
1013
+ if (hasOwn(node, "enum")) {
1014
+ if (!Array.isArray(node.enum))
1015
+ return null;
1016
+ const permitsNull = node.enum.some((member) => member === null);
1017
+ const permitsNonNull = node.enum.some((member) => member !== null);
1018
+ if (!permitsNull || !permitsNonNull)
1019
+ return null;
1020
+ }
1021
+ // Only a `required` name that actually REACHES the parent scope forces the
1022
+ // collapse. A branch whose `required` is satisfied by its own `properties`
1023
+ // loses nothing by being rewritten, and declining there would keep the
1024
+ // invented-value failure for a node that has no need of the exception. Key
1025
+ // names survive sanitization unchanged, so the raw map answers this.
1026
+ if (parentProperties != null && Array.isArray(node.required)) {
1027
+ const own = isPlainObject(node.properties) ? node.properties : null;
1028
+ const reachesParentScope = node.required.some((name) => typeof name === "string" && !(own !== null && hasOwn(own, name)));
1029
+ if (reachesParentScope)
1030
+ return null;
1031
+ }
1032
+ return typed;
1033
+ }
1034
+ /**
1035
+ * Build the typed branch of a nullable rewrite: the node's own type-bearing
1036
+ * keywords (`NULLABLE_BRANCH_KEYS`) under a scalar `type`, with `null` removed
1037
+ * from an `enum` because the sibling `{type:"null"}` branch already carries
1038
+ * that fact — and leaving it would make transform #2 emit a "May also be null."
1039
+ * note the schema no longer needs.
1040
+ *
1041
+ * The branch is run back through `sanitizeSchemaNode` rather than assembled by
1042
+ * hand so every per-node transform applies to it exactly as it would to any
1043
+ * scalar-typed node: transform #12 retypes it to `"object"` when it carries
1044
+ * `properties`, transform #13 drops an unsupported `pattern` into prose on the
1045
+ * branch, transform #3 resolves its `required` against its own `properties`,
1046
+ * and transform #2 filters its `enum`.
1047
+ *
1048
+ * Sanitized at THIS node's depth, not one deeper. The branch is a
1049
+ * re-expression of the node, so its children sit exactly where they sat before
1050
+ * the rewrite, and the depth budget they are charged is unchanged. No
1051
+ * `parentProperties` is passed: the only branch that could have used one — a
1052
+ * composition branch carrying `required` — is excluded by `nullableUnionType`.
1053
+ */
1054
+ function buildNullableBranch(node, typeName, depth) {
1055
+ const branch = { type: typeName };
1056
+ for (const [key, value] of Object.entries(node)) {
1057
+ if (!NULLABLE_BRANCH_KEYS.has(key))
1058
+ continue;
1059
+ if (key === "enum" && Array.isArray(value)) {
1060
+ branch.enum = value.filter((member) => member !== null);
1061
+ continue;
1062
+ }
1063
+ safeSet(branch, key, value);
1064
+ }
1065
+ return sanitizeSchemaNode(branch, depth);
1066
+ }
603
1067
  /**
604
1068
  * @param parentProperties When this node is a composition branch, the enclosing
605
1069
  * node's SANITIZED `properties` map — the extra scope its `required` may name
606
1070
  * (transform #8). `undefined`/`null` for every other node, which keeps
607
1071
  * transform #3's own-properties-only rule exactly as it was.
1072
+ * @param isRootCompositionBranch True when this node is an element of a
1073
+ * composition on the parameters ROOT. Only transform #1's nullable rewrite
1074
+ * reads it, and only to decline: such a branch is judged individually by
1075
+ * transform #9 rule (a), which rejects any branch carrying its own
1076
+ * `anyOf`/`oneOf`.
608
1077
  */
609
- function sanitizeSchemaNode(node, depth, parentProperties) {
1078
+ function sanitizeSchemaNode(node, depth, parentProperties, isRootCompositionBranch = false) {
610
1079
  // Depth guard: stop walking absurdly nested input rather than overflowing
611
1080
  // the stack. Real tool schemas are a few levels deep; anything past
612
1081
  // MAX_DEPTH is adversarial, so the subtree is replaced with the
@@ -616,10 +1085,27 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
616
1085
  if (depth > MAX_DEPTH)
617
1086
  return {};
618
1087
  const childDepth = depth + 1;
1088
+ // Transform #1, nullable case. A NON-root `{type:["T","null"], …}` is emitted
1089
+ // as `anyOf: [<T branch>, {type:"null"}]` rather than collapsed to `T` with a
1090
+ // prose note, because prose loses to the schema: a model reading
1091
+ // `{type:"string", minLength:1}` cannot express "none" in the half of the
1092
+ // declaration it treats as binding, so it invents a value that satisfies the
1093
+ // type instead (see the module header). Decided first because everything
1094
+ // below — the `properties` pre-pass, the type resolution, the enum rule, the
1095
+ // notes — belongs to the BRANCH when the rewrite fires, not to the node that
1096
+ // now carries only the union and its annotations.
1097
+ const nullableType = depth > 0
1098
+ ? nullableUnionType(node, parentProperties, isRootCompositionBranch)
1099
+ : null;
1100
+ const nullableBranch = nullableType === null
1101
+ ? null
1102
+ : buildNullableBranch(node, nullableType, depth);
619
1103
  // Sanitized `properties`, computed ahead of the key walk because transform #8
620
1104
  // needs it before the walk reaches whichever of `properties` / `oneOf` comes
621
1105
  // first in key order. Emitted verbatim when the walk reaches `properties`.
622
- const sanitizedProperties = hasOwn(node, "properties")
1106
+ // Skipped entirely under a nullable rewrite, where `properties` has moved
1107
+ // onto the typed branch and was sanitized there.
1108
+ const sanitizedProperties = nullableType === null && hasOwn(node, "properties")
623
1109
  ? sanitizePropertiesKeyword(node.properties, childDepth)
624
1110
  : undefined;
625
1111
  const ownProperties = isPlainObject(sanitizedProperties)
@@ -643,15 +1129,25 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
643
1129
  const declaresProperty = (name) => (ownProperties !== null && hasOwn(ownProperties, name)) ||
644
1130
  (parentProperties != null && hasOwn(parentProperties, name));
645
1131
  // Resolve the node's effective single `type`, collapsing a JSON Schema
646
- // `type` ARRAY (a union, e.g. `["string","number","boolean"]` or a nullable
647
- // `["string","null"]`) to the first non-"null" member. Gemini's
648
- // function-declaration schema requires a single `type` and hard-rejects a
649
- // type array, which manifests as a misleading downstream error. Computed up
650
- // front (not in key order) because the `enum` decision below depends on it.
1132
+ // `type` ARRAY (a MULTI-type union, e.g. `["string","number","boolean"]`) to
1133
+ // the first non-"null" member. Gemini's function-declaration schema requires
1134
+ // a single `type` and hard-rejects a type array, which manifests as a
1135
+ // misleading downstream error. Computed up front (not in key order) because
1136
+ // the `enum` decision below depends on it. The nullable case
1137
+ // (`["string","null"]`) took the `anyOf` rewrite above and never gets here —
1138
+ // except at the root and the two branch positions that decline it, which
1139
+ // collapse like any other union.
1140
+ const notesFromRetype = [];
651
1141
  let typeToEmit = node.type;
652
1142
  let emitType = "type" in node;
653
1143
  let singleType;
654
- if (Array.isArray(node.type)) {
1144
+ if (nullableType !== null) {
1145
+ // The `type` key's slot is taken by the `anyOf` emitted in the walk below,
1146
+ // so nothing resolves a scalar type here and no note is manufactured: the
1147
+ // union states the nullability the note used to approximate.
1148
+ emitType = false;
1149
+ }
1150
+ else if (Array.isArray(node.type)) {
655
1151
  const nonNull = node.type.filter((t) => t !== "null");
656
1152
  if (nonNull.length > 0) {
657
1153
  typeToEmit = nonNull[0];
@@ -665,6 +1161,30 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
665
1161
  else if (typeof node.type === "string") {
666
1162
  singleType = node.type;
667
1163
  }
1164
+ // Transform #12: a node carrying `properties` IS an object, and Gemini
1165
+ // requires it to say so — "For schema with properties, schema type should be
1166
+ // OBJECT" on all of gemini-3.5/3.6/3.7-flash, both when the `type` is missing
1167
+ // and when it contradicts (`{type:"string", properties:{…}}` fails the same
1168
+ // way). Not composition-specific: a plain property subschema and an `items`
1169
+ // schema fail identically. xAI and OpenAI accept every one of those forms,
1170
+ // which is why it survived until a property sweep against live inference went
1171
+ // looking.
1172
+ //
1173
+ // Resolved HERE rather than patched onto the finished node so the rest of the
1174
+ // walk sees the real type: `dropEnum` below keys off it, and an `enum` left
1175
+ // behind on a node that has just become an object would be dropped by the
1176
+ // NEXT pass instead of this one — an idempotence break.
1177
+ const retypedFromProperties = isPlainObject(sanitizedProperties) && singleType !== "object";
1178
+ if (retypedFromProperties) {
1179
+ if (singleType !== undefined) {
1180
+ // A contradictory declared type is discarded; record it like transform #1
1181
+ // records a collapsed union.
1182
+ notesFromRetype.push(`Declared as ${singleType}, but carries properties, so it is treated as an object.`);
1183
+ }
1184
+ typeToEmit = "object";
1185
+ emitType = true;
1186
+ singleType = "object";
1187
+ }
668
1188
  // Gemini accepts `enum` only on string-typed properties. Drop it outright
669
1189
  // when the node has an explicit NON-string type (boolean/number/integer/
670
1190
  // array/object/null); when the type is "string" or absent, the enum is
@@ -675,8 +1195,8 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
675
1195
  // could use. Fold that information into the node's `description` (as prose,
676
1196
  // which every provider accepts) so the model still sees it. Computed up
677
1197
  // front so it can be appended wherever `description` appears in key order.
678
- const notes = [];
679
- if (Array.isArray(node.type)) {
1198
+ const notes = [...notesFromRetype];
1199
+ if (nullableType === null && Array.isArray(node.type)) {
680
1200
  const typeNames = node.type.filter((t) => typeof t === "string");
681
1201
  if (typeNames.length > 1) {
682
1202
  notes.push(`Accepts ${humanJoin(typeNames)}.`);
@@ -686,7 +1206,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
686
1206
  notes.push("Must be null.");
687
1207
  }
688
1208
  }
689
- if (Array.isArray(node.enum)) {
1209
+ if (nullableType === null && Array.isArray(node.enum)) {
690
1210
  const stringMembers = node.enum.filter((v) => typeof v === "string");
691
1211
  if (dropEnum || stringMembers.length === 0) {
692
1212
  // The whole enum is dropped (non-string type, or no string members
@@ -700,6 +1220,18 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
700
1220
  notes.push(`May also be ${humanJoin(dropped.map(describeEnumValue))}.`);
701
1221
  }
702
1222
  }
1223
+ // Transform #13, decided up front so its note joins the others before
1224
+ // `descNote` is frozen and the loop below can simply skip the keyword.
1225
+ const dropPattern = nullableType === null &&
1226
+ typeof node.pattern === "string" &&
1227
+ usesUnsupportedRegexConstruct(node.pattern);
1228
+ if (dropPattern) {
1229
+ const pattern = node.pattern;
1230
+ const shown = pattern.length > MAX_PATTERN_IN_PROSE
1231
+ ? `${pattern.slice(0, MAX_PATTERN_IN_PROSE)}…`
1232
+ : pattern;
1233
+ notes.push(`Should match the pattern ${shown}.`);
1234
+ }
703
1235
  const descNote = notes.join(" ");
704
1236
  let descriptionEmitted = false;
705
1237
  const out = {};
@@ -711,7 +1243,19 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
711
1243
  // by those names are handled safely inside `sanitizeSchemaMap`.)
712
1244
  if (PROTO_KEYS.has(key))
713
1245
  continue;
1246
+ // Transform #1, nullable case: every type-bearing keyword has moved onto
1247
+ // the typed branch, which was sanitized as a node in its own right. Only
1248
+ // the node's annotations (and any keyword outside both sets) stay here.
1249
+ if (nullableType !== null && NULLABLE_BRANCH_KEYS.has(key))
1250
+ continue;
714
1251
  if (key === "type") {
1252
+ if (nullableBranch !== null) {
1253
+ // Emitted in the `type` key's own position so key order stays
1254
+ // deterministic — which is what lets a second pass, finding an `anyOf`
1255
+ // it leaves alone, reproduce this object exactly.
1256
+ out.anyOf = [nullableBranch, { type: "null" }];
1257
+ continue;
1258
+ }
715
1259
  if (emitType)
716
1260
  out.type = typeToEmit;
717
1261
  continue;
@@ -752,6 +1296,17 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
752
1296
  // trip a strict validator) — drop it.
753
1297
  continue;
754
1298
  }
1299
+ if (key === "pattern") {
1300
+ // Transform #13: a regex construct some provider refuses takes the WHOLE
1301
+ // request down, so the pattern is dropped and echoed as prose instead —
1302
+ // the model still sees the intent, and arguments are validated at
1303
+ // dispatch and by the remote server regardless. A supported pattern is a
1304
+ // real constraint every provider honours and is kept verbatim.
1305
+ if (dropPattern)
1306
+ continue;
1307
+ out.pattern = value;
1308
+ continue;
1309
+ }
755
1310
  if (key === "dependencies") {
756
1311
  // draft-07: name → (subschema | string[]). Subschema values are
757
1312
  // sanitized (and self-prune their own `required`); string[] values are
@@ -795,6 +1350,14 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
795
1350
  continue;
796
1351
  }
797
1352
  if (COMPOSITION_KEYS.has(key)) {
1353
+ // Transform #10: an EMPTY array-valued keyword is a hard 400 on xAI at
1354
+ // any depth — `/properties/a/oneOf: [] has less than 1 item` — for all
1355
+ // three composition keywords and for `prefixItems` below. It carries no
1356
+ // constraint anyway (an `allOf` of nothing is satisfied by everything), so
1357
+ // it is omitted rather than emitted. Empty OBJECTS (`properties: {}`,
1358
+ // `$defs: {}`) and an empty `required: []` are accepted and unaffected.
1359
+ if (Array.isArray(value) && value.length === 0)
1360
+ continue;
798
1361
  // Transform #8: each element is evaluated against THIS node, so it is
799
1362
  // handed this node's property scope. The self-sufficiency repair is
800
1363
  // applied at the parameters root only (`depth === 0`) — nested
@@ -808,6 +1371,9 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
808
1371
  continue;
809
1372
  }
810
1373
  if (SUBSCHEMA_ARRAY_KEYS.has(key)) {
1374
+ // `prefixItems: []` — same empty-array rejection as the compositions above.
1375
+ if (Array.isArray(value) && value.length === 0)
1376
+ continue;
811
1377
  out[key] = Array.isArray(value)
812
1378
  ? value.map((v) => sanitizeSubschema(v, childDepth))
813
1379
  : value;
@@ -824,6 +1390,11 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
824
1390
  // them to, add one so the dropped information still reaches the model.
825
1391
  if (descNote && !descriptionEmitted)
826
1392
  out.description = descNote;
1393
+ // Transform #12, emission half. A node that DECLARED a `type` had it rewritten
1394
+ // in the key loop above; one that declared none needs the key appended, since
1395
+ // the loop only ever emits keys the node actually had.
1396
+ if (retypedFromProperties && !hasOwn(node, "type"))
1397
+ out.type = "object";
827
1398
  return out;
828
1399
  }
829
1400
  /**
@@ -1003,11 +1574,34 @@ function isUsableRootBranch(branch) {
1003
1574
  return false;
1004
1575
  if (Object.keys(branch).length === 0)
1005
1576
  return false;
1006
- // Deliberately NOT recursive: a branch carrying only a nested composition
1007
- // (`{oneOf: [{anyOf: [{type:"object",…}, …]}]}`) is rejected 0/3 on Grok even
1008
- // with `type: "object"` stamped on the root, so counting it usable because its
1009
- // own elements are object-shaped would reintroduce the 400. Such a root drops
1010
- // to `{}`, which every model accepts see the known-loss list in the header.
1577
+ // A branch carrying its OWN `anyOf`/`oneOf` is unusable however object-shaped
1578
+ // it otherwise looks: `{type:"object", anyOf:[…]}` as a root union branch is
1579
+ // 0/3 on grok-4.3/4.5/4.6 (and a branch carrying BOTH `allOf` and `anyOf` is
1580
+ // 0/3 too the union is what poisons it), while every Gemini and GPT model
1581
+ // accepts it. The `type` is not the problem, so the checks below never see it.
1582
+ //
1583
+ // This is NOT the recursion that was rejected earlier. Making such a branch
1584
+ // *usable* is impossible — that was the right call. But "cannot be made
1585
+ // usable" means it must be REPORTED unusable, which routes it into the drop
1586
+ // path that already handles the other unrepairable branch shapes correctly.
1587
+ //
1588
+ // Scope is the branch's OWN keys, never its subtree. A composition on a
1589
+ // branch's PROPERTY is accepted 9/9 (`{type:"object", properties:{a:{anyOf:
1590
+ // […]}}}`), as is one two levels down, so a subtree scan would destroy
1591
+ // constraints every provider honours. `ROOT_UNION_KEYS`, not
1592
+ // `COMPOSITION_KEYS`: a branch's own `allOf` is accepted 9/9 — including with
1593
+ // a bare-`required` sub-branch — which is the same intersection-vs-union
1594
+ // asymmetry xAI applies to the root itself.
1595
+ if (ROOT_UNION_KEYS.some((key) => hasOwn(branch, key)))
1596
+ return false;
1597
+ // A `$ref` disqualifies a branch too, and NOT only when it stands alone:
1598
+ // `{$ref, type:"object"}` and `{$ref, properties}` are both 0/3 on
1599
+ // grok-4.3/4.5/4.6 (accepted by Gemini and OpenAI). xAI does not resolve a
1600
+ // union branch's `$ref` at all, so sibling object keywords do not redeem it.
1601
+ // Transform #14 gets first refusal on these — it inlines the target and keeps
1602
+ // the siblings — so only an un-inlinable one reaches the drop.
1603
+ if (hasOwn(branch, "$ref"))
1604
+ return false;
1011
1605
  // A declared `type` decides on its own: `properties` alongside an explicit
1012
1606
  // NON-object type is a self-contradictory branch (object keywords on a
1013
1607
  // non-object schema), and measured, xAI rejects it exactly as it rejects a
@@ -1027,7 +1621,14 @@ function isUsableRootBranch(branch) {
1027
1621
  * branches) — but a root carrying only `$defs`, or only an `allOf`, is a 400.
1028
1622
  */
1029
1623
  function isObjectRoot(root) {
1030
- if (root.type === "object" || isPlainObject(root.properties))
1624
+ // A declared `type` decides first, and it is the strictest rule measured
1625
+ // anywhere in this module: a root whose `type` is not "object" is rejected by
1626
+ // grok-4.3/4.5/4.6, gpt-5.6-sol AND gemini-3.7-flash — 0/5 — with or without
1627
+ // a `properties` map alongside it. Every other root rule has at least one
1628
+ // tolerant provider; this one has none.
1629
+ if (hasOwn(root, "type"))
1630
+ return root.type === "object";
1631
+ if (isPlainObject(root.properties))
1031
1632
  return true;
1032
1633
  if (Object.keys(root).length === 0)
1033
1634
  return true;
@@ -1059,6 +1660,74 @@ function isObjectRoot(root) {
1059
1660
  * Runs after `flattenRootAllOf`, so a root `allOf` that flattened losslessly is
1060
1661
  * already gone and only an un-flattenable one reaches rule (b).
1061
1662
  */
1663
+ /**
1664
+ * Transform #14: inline a ROOT union branch that is a local `$ref`, so the union
1665
+ * survives rule (a) instead of being dropped whole.
1666
+ *
1667
+ * `z.union([A, B])` renders as `{anyOf: [{$ref}, {$ref}], $defs}`, and xAI does
1668
+ * not resolve a `$ref` union branch — 0/3 even with `type: "object"` on the
1669
+ * root. Rule (a) therefore dropped the keyword and, with the properties living
1670
+ * in `$defs`, the tool was left advertising no parameters at all. Inlining the
1671
+ * target makes the branch an ordinary object branch, which is accepted 9/9, and
1672
+ * keeps the constraint the union was expressing.
1673
+ *
1674
+ * Only branches that are NOT already usable are inlined, so a well-formed union
1675
+ * is untouched and the emitted schema does not grow for no reason. One level
1676
+ * only: a `$ref` inside the inlined target is left to resolve on its own, which
1677
+ * is both what providers accept and what makes a recursive definition
1678
+ * terminate here rather than loop. Sibling keywords on the branch are kept and
1679
+ * win over the target's, matching 2020-12 `$ref` semantics. The branch count is
1680
+ * capped for the same reason the copy-down is — both counts are
1681
+ * attacker-controlled, and the inlined objects are shared references that expand
1682
+ * on `JSON.stringify`.
1683
+ */
1684
+ function inlineRootUnionRefs(root) {
1685
+ let changed = false;
1686
+ const out = {};
1687
+ // ONE budget for the whole root, not one per keyword: a root carrying both an
1688
+ // `anyOf` and a `oneOf` would otherwise get twice the ceiling, which
1689
+ // contradicts the "single ceiling across one root composition" invariant that
1690
+ // transform #8's copy-down also states.
1691
+ let budget = MAX_ROOT_COPY_DOWN;
1692
+ for (const [key, value] of Object.entries(root)) {
1693
+ if (!ROOT_UNION_KEYS.includes(key) || !Array.isArray(value)) {
1694
+ safeSet(out, key, value);
1695
+ continue;
1696
+ }
1697
+ const branches = value.map((branch) => {
1698
+ if (isUsableRootBranch(branch))
1699
+ return branch;
1700
+ if (!isPlainObject(branch) || typeof branch.$ref !== "string")
1701
+ return branch;
1702
+ const ref = branch.$ref;
1703
+ if (ref !== "#" && !ref.startsWith("#/"))
1704
+ return branch;
1705
+ const target = resolveLocalPointer(root, ref);
1706
+ // A self-referential root pointer would inline the whole document into
1707
+ // one of its own branches; nothing useful, and unbounded.
1708
+ if (!isPlainObject(target) || target === root)
1709
+ return branch;
1710
+ // Priced by EXPANDED size — the whole subtree that will be duplicated
1711
+ // into the request body — not by the target's top-level key count.
1712
+ const size = subtreeWeight(target, budget);
1713
+ if (size > budget)
1714
+ return branch;
1715
+ budget -= size;
1716
+ const inlined = {};
1717
+ for (const [k, v] of Object.entries(target))
1718
+ safeSet(inlined, k, v);
1719
+ for (const [k, v] of Object.entries(branch)) {
1720
+ if (k === "$ref")
1721
+ continue;
1722
+ safeSet(inlined, k, v);
1723
+ }
1724
+ changed = true;
1725
+ return inlined;
1726
+ });
1727
+ out[key] = branches;
1728
+ }
1729
+ return changed ? out : root;
1730
+ }
1062
1731
  function dropUnusableRootUnions(root) {
1063
1732
  // All-or-nothing, and deliberately so. ONE unusable branch poisons the whole
1064
1733
  // keyword on xAI — `{type:"object", properties:{…}, oneOf:[{type:"object",
@@ -1094,25 +1763,204 @@ function dropUnusableRootUnions(root) {
1094
1763
  }
1095
1764
  return out;
1096
1765
  }
1766
+ /**
1767
+ * Keywords that make xAI classify the parameters ROOT as something other than an
1768
+ * object schema, whatever else it declares. Measured on grok-4.5: a root
1769
+ * carrying `const` is `root schema is a const` and a root carrying `$ref` is
1770
+ * `root schema is a $ref` — both 400, both even when the root also declares
1771
+ * `type: "object"` AND `properties`, and the `$ref` case even when the target
1772
+ * resolves. Neither can describe a callable argument object, so both are dropped
1773
+ * at the root and their information folded into the description. Nested `const`
1774
+ * and `$ref` are meaningful and untouched.
1775
+ */
1776
+ const ROOT_REJECTED_KEYWORDS = ["const", "$ref"];
1097
1777
  /**
1098
1778
  * Transform #9, rule (b). Runs AFTER `flattenRootAllOf`, so a root `allOf` that
1099
1779
  * merged losslessly is already gone and only a composition that survived — or a
1100
- * root left bare by rule (a) — reaches this check.
1101
- *
1102
- * `carriedComposition` is evaluated on the pre-drop root: a root reduced to
1103
- * `{$defs: …}` by rule (a) still needs typing, even though it no longer carries
1104
- * a composition keyword to point at.
1780
+ * root left bare by rule (a) — reaches this check. Applies to EVERY root, not
1781
+ * just composition roots.
1105
1782
  */
1106
- function ensureObjectRoot(root, carriedComposition) {
1107
- // Scoped to composition roots the roots transforms #5/#8/#9 own — and never
1108
- // overrides a `type` the schema declared for itself.
1109
- if (!carriedComposition || hasOwn(root, "type") || isObjectRoot(root)) {
1783
+ function ensureObjectRoot(root) {
1784
+ const rejected = ROOT_REJECTED_KEYWORDS.filter((key) => hasOwn(root, key));
1785
+ if (rejected.length > 0) {
1786
+ const stripped = {};
1787
+ for (const [key, value] of Object.entries(root)) {
1788
+ if (rejected.includes(key))
1789
+ continue;
1790
+ safeSet(stripped, key, value);
1791
+ }
1792
+ const note = rejected
1793
+ .map((key) => key === "const"
1794
+ ? `Pinned to a single value (${describeEnumValue(root.const)}) at the schema root.`
1795
+ : `Declared as a reference (${describeEnumValue(root.$ref)}) at the schema root.`)
1796
+ .join(" ");
1797
+ stripped.description =
1798
+ typeof stripped.description === "string" && stripped.description.length > 0
1799
+ ? `${stripped.description} ${note}`
1800
+ : note;
1801
+ return ensureObjectRoot(stripped);
1802
+ }
1803
+ if (isObjectRoot(root))
1110
1804
  return root;
1805
+ // Applies to EVERY root, not just composition roots. A property-based sweep
1806
+ // over recursive schema shapes found the narrower gate emitting unusable
1807
+ // roots for whole classes the example fixtures never covered: a scalar or
1808
+ // array root (`{type:"string"}`, reachable from transform #1 collapsing
1809
+ // `{type:["string","null"]}`), an annotation-only root (`{description}`), a
1810
+ // `$defs`-only root, and a root whose `type` contradicts its `properties`.
1811
+ // Measured, all of them fail Grok, the scalar ones also fail gpt-5.6-sol, and
1812
+ // the contradictory-type one fails all nine. `{type:"object"}` plus whatever
1813
+ // else the root carried is accepted 9/9 in every case.
1814
+ //
1815
+ // Forcing `type: "object"` over a declared scalar type is a rewrite, not a
1816
+ // repair, and it is the right one: function-call arguments are always a named
1817
+ // object on the wire, so a scalar parameters root cannot describe a callable
1818
+ // tool no matter which provider reads it. Sibling keywords are left as they
1819
+ // are — an `items` stranded on an object root is inert, and every model
1820
+ // accepts it, so removing it would be surgery with no measured benefit.
1821
+ const declaredType = typeof root.type === "string" ? root.type : undefined;
1822
+ const notes = [];
1823
+ const out = { type: "object" };
1824
+ for (const [key, value] of Object.entries(root)) {
1825
+ if (key === "type")
1826
+ continue;
1827
+ // `enum` is the ONE keyword whose treatment depends on the node's type
1828
+ // (transform #2 accepts it only on a string-typed or type-less node), so
1829
+ // retyping the root to "object" while keeping it would leave a root the
1830
+ // node walk would sanitize differently on a second pass — i.e. break
1831
+ // idempotence, which the property sweep catches immediately. Drop it here
1832
+ // and surface the values as prose, exactly as transform #2 does.
1833
+ if (key === "enum") {
1834
+ if (Array.isArray(value) && value.length > 0) {
1835
+ notes.push(`Allowed values: ${value.map(describeEnumValue).join(", ")}.`);
1836
+ }
1837
+ continue;
1838
+ }
1839
+ safeSet(out, key, value);
1111
1840
  }
1112
- const typed = { type: "object" };
1113
- for (const [key, value] of Object.entries(root))
1114
- safeSet(typed, key, value);
1115
- return typed;
1841
+ if (declaredType !== undefined) {
1842
+ // Fold the discarded type into the description, as transforms #1 and #2 do
1843
+ // for the constraints they drop.
1844
+ notes.push(`Declared as ${declaredType} at the schema root; arguments are passed as an object.`);
1845
+ }
1846
+ // `enum` is dropped above whenever the root is retyped, INCLUDING when it
1847
+ // declared no type at all (`{enum:["a"]}`). Keying that on a declared scalar
1848
+ // type left an `enum` sitting on a now-object root, which the next pass would
1849
+ // drop under transform #2 — an idempotence break the generator never reached
1850
+ // because it emitted no type-less `enum` root.
1851
+ if (notes.length > 0) {
1852
+ const note = notes.join(" ");
1853
+ out.description =
1854
+ typeof out.description === "string" && out.description.length > 0
1855
+ ? `${out.description} ${note}`
1856
+ : note;
1857
+ }
1858
+ return out;
1859
+ }
1860
+ /**
1861
+ * Resolve a local JSON Pointer (`#/a/b`) against the document root, returning
1862
+ * `undefined` when any segment is missing. Segment unescaping is the RFC 6901
1863
+ * order — `~1` before `~0` — because doing it the other way turns a literal
1864
+ * `~01` into `/` instead of `~1`.
1865
+ */
1866
+ function resolveLocalPointer(root, ref) {
1867
+ if (ref === "#")
1868
+ return root;
1869
+ const path = ref.slice(2);
1870
+ // `#/` and `#//`-style paths carry no real segments; RFC 6901 makes an empty
1871
+ // path the document root, and treating "" as a property name would prune a
1872
+ // reference that is technically valid.
1873
+ if (path === "")
1874
+ return root;
1875
+ const segments = path.split("/");
1876
+ let current = root;
1877
+ for (const raw of segments) {
1878
+ // A `$ref` is a URI, so its fragment is percent-encoded: `#/$defs/a%20b`
1879
+ // addresses the key `"a b"`. Decode that layer FIRST, then the pointer
1880
+ // layer's `~1`/`~0`. Skipping the decode strips a perfectly valid reference
1881
+ // as unresolvable and silently loses the constraint it carried. A malformed
1882
+ // escape (`%zz`) throws, and the raw text is the right fallback there.
1883
+ let decoded;
1884
+ try {
1885
+ decoded = decodeURIComponent(raw);
1886
+ }
1887
+ catch {
1888
+ decoded = raw;
1889
+ }
1890
+ const segment = decoded.replace(/~1/g, "/").replace(/~0/g, "~");
1891
+ if (Array.isArray(current)) {
1892
+ const index = Number(segment);
1893
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
1894
+ return undefined;
1895
+ }
1896
+ current = current[index];
1897
+ continue;
1898
+ }
1899
+ if (!isPlainObject(current) || !hasOwn(current, segment))
1900
+ return undefined;
1901
+ current = current[segment];
1902
+ }
1903
+ return current;
1904
+ }
1905
+ /**
1906
+ * Transform #11: drop a `$ref` whose LOCAL target does not resolve.
1907
+ *
1908
+ * xAI resolves `#/...` pointers itself and 400s the whole request when one
1909
+ * dangles — `unresolvable $ref '#/$defs/Nope': key 'Nope' not found under
1910
+ * '#/$defs'`, and `key '$defs' not found in schema` when the document has no
1911
+ * `$defs` at all. This is easy to produce in practice: a third-party MCP server
1912
+ * that ships a subschema without its definitions, or one whose `$defs` sits on a
1913
+ * nested node (a `#/$defs/X` pointer is anchored at the document ROOT, so a
1914
+ * nested `$defs` is genuinely unaddressable that way).
1915
+ *
1916
+ * Only local pointers are checked. An external `$ref` (`https://…`) is accepted
1917
+ * by xAI and left alone, as is a resolvable non-`$defs` pointer such as
1918
+ * `#/properties/a` — both measured. Dropping only the `$ref` keyword leaves the
1919
+ * rest of the node intact; a node that was nothing but a dangling `$ref` becomes
1920
+ * the unconstrained `{}`, which every model accepts. This runs after the node
1921
+ * walk, so it resolves against the SANITIZED document — the one actually sent.
1922
+ */
1923
+ function pruneUnresolvableRefs(node, root, depth, state = { changed: false }) {
1924
+ // `{}` rather than `true`, matching `sanitizeSchemaNode`'s guard: a boolean
1925
+ // schema is not a plain object, so `canLosslesslyFlattenAllOf` would refuse a
1926
+ // branch it would otherwise merge.
1927
+ if (depth > MAX_DEPTH)
1928
+ return {};
1929
+ if (Array.isArray(node)) {
1930
+ return node.map((item) => pruneUnresolvableRefs(item, root, depth + 1, state));
1931
+ }
1932
+ if (!isPlainObject(node))
1933
+ return node;
1934
+ const out = {};
1935
+ for (const [key, value] of Object.entries(node)) {
1936
+ if (key === "$ref" &&
1937
+ typeof value === "string" &&
1938
+ (value === "#" || value.startsWith("#/")) &&
1939
+ resolveLocalPointer(root, value) === undefined) {
1940
+ state.changed = true;
1941
+ continue;
1942
+ }
1943
+ // Opaque data keywords may legitimately contain a `$ref`-named key that is
1944
+ // not a reference (the walk treats them as data for the same reason), so
1945
+ // they are copied rather than descended into.
1946
+ if (key === "enum" || key === "const" || key === "default" || key === "examples") {
1947
+ safeSet(out, key, value);
1948
+ continue;
1949
+ }
1950
+ safeSet(out, key, pruneUnresolvableRefs(value, root, depth + 1, state));
1951
+ }
1952
+ return out;
1953
+ }
1954
+ /** Prune against `root`, returning the ORIGINAL reference when nothing was
1955
+ * dropped so the pipeline can detect a fixed point without a deep compare
1956
+ * (this module deliberately never hands an untrusted value to
1957
+ * `JSON.stringify`, whose own recursion would outrun the depth guard). */
1958
+ function prunedOrSame(root) {
1959
+ const state = { changed: false };
1960
+ const pruned = pruneUnresolvableRefs(root, root, 0, state);
1961
+ if (!state.changed)
1962
+ return root;
1963
+ return isPlainObject(pruned) ? pruned : {};
1116
1964
  }
1117
1965
  /**
1118
1966
  * Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see
@@ -1129,12 +1977,37 @@ export function sanitizeToolSchema(schema) {
1129
1977
  if (!isPlainObject(schema))
1130
1978
  return {};
1131
1979
  const walked = sanitizeSchemaNode(schema, 0);
1132
- const carriedComposition = [...COMPOSITION_KEYS].some((key) => Array.isArray(walked[key]));
1133
1980
  // Rule (a) runs BEFORE the flatten, not after. `canLosslesslyFlattenAllOf`
1134
1981
  // refuses to merge an `allOf` while a `oneOf`/`anyOf` sibling is present (the
1135
1982
  // root is then not a pure intersection) — so dropping an unusable union
1136
1983
  // afterwards would leave behind an `allOf` that the NEXT pass would flatten,
1137
1984
  // breaking idempotence. Removing the union first makes the root a genuine
1138
1985
  // intersection, which is exactly when flattening it is correct.
1139
- return ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(walked)), carriedComposition);
1986
+ // The root passes are run to a FIXED POINT rather than once.
1987
+ //
1988
+ // Ordering alone cannot satisfy both constraints. Transform #11 has to run
1989
+ // BEFORE the flatten, because dropping a dangling `$ref` can empty the node
1990
+ // that carried it and an emptied `allOf` branch is merge-safe — pruning after
1991
+ // would strand a composition for the NEXT pass to merge. But it also has to
1992
+ // run AFTER the root rewrites, because those remove root keywords a nested
1993
+ // `$ref` may point at: `{properties:{x:{$ref:"#/const"}}, const:{}}` loses its
1994
+ // `const` to transform #9 and is left with exactly the dangling reference #11
1995
+ // exists to prevent, and a flatten can invalidate `#/allOf/0` the same way.
1996
+ //
1997
+ // Iterating settles it: each pass returns its INPUT REFERENCE when it changes
1998
+ // nothing, so a round that touches nothing ends the loop, and the bound keeps
1999
+ // a pathological schema from spinning. Two rounds cover every shape the
2000
+ // property sweep generates; the third is there so the bound is a backstop
2001
+ // rather than the mechanism.
2002
+ let doc = walked;
2003
+ for (let round = 0; round < 3; round++) {
2004
+ const before = doc;
2005
+ // #14 precedes rule (a): an inlinable `$ref` branch becomes an ordinary
2006
+ // object branch, so the union is kept rather than dropped. Anything still
2007
+ // unusable falls through to the drop.
2008
+ doc = ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(inlineRootUnionRefs(prunedOrSame(doc)))));
2009
+ if (doc === before)
2010
+ break;
2011
+ }
2012
+ return doc;
1140
2013
  }