@juno-ai/bind 12.0.0 → 13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, the plugin/tool vocabulary, and the skill vocabulary (`./skills`) for progressive knowledge disclosure. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -174,8 +174,11 @@
174
174
  * `type: "object"` to such a root makes exactly those cases pass 9/9
175
175
  * without touching the composition — so the un-flattenable root `allOf`
176
176
  * 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.
177
+ * discarded, and it stops being the silent 400 it is today. A non-object
178
+ * `type` the root declared for itself IS rewritten to `"object"` and
179
+ * recorded in the description — measured, such a root is rejected by
180
+ * every model. Nested nodes keep their declared types unless transform
181
+ * #12 applies.
179
182
  *
180
183
  * Rule (a) is all-or-nothing per keyword, because ONE unusable branch
181
184
  * poisons the whole union on xAI even when its siblings are good (0/3 with a
@@ -187,12 +190,34 @@
187
190
  * callable and a wrong COMBINATION comes back from dispatch/the remote
188
191
  * server as a recoverable tool error.
189
192
  *
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).
193
+ * A branch's OWN `anyOf`/`oneOf` disqualifies it too, whatever else it
194
+ * declares: `{type:"object", anyOf:[…]}` as a root union branch is 0/3 on
195
+ * Grok, as is a branch carrying both `allOf` and `anyOf` the union is what
196
+ * poisons it. Scope is the branch's own keys and never its subtree: a
197
+ * composition on a branch's PROPERTY is accepted 9/9, one or two levels
198
+ * down, so a subtree scan would destroy constraints every provider honours.
199
+ * A branch's own `allOf` is likewise accepted 9/9 (even with a
200
+ * bare-`required` sub-branch), the same intersection-vs-union asymmetry xAI
201
+ * applies to the root — hence `ROOT_UNION_KEYS`, not `COMPOSITION_KEYS`.
202
+ *
203
+ * A root union of `$ref` branches is no longer a loss — transform #14 below
204
+ * inlines the targets so the union survives. What remains is the narrow
205
+ * residue it cannot inline: a target that is not a plain object schema, one
206
+ * whose expanded size exceeds the copy-down ceiling, and a self-referential
207
+ * root pointer. Those branches stay unusable and still drop the keyword.
208
+ *
209
+ * Rule (b) covers EVERY root, not only composition roots. A property sweep
210
+ * over recursive shapes (`__tests__/tool-schema/property/`) found the
211
+ * narrower gate emitting unusable roots for whole classes the fixtures
212
+ * never reached: a scalar or array root (reachable from transform #1
213
+ * collapsing `{type:["string","null"]}`), an annotation-only root, a
214
+ * `$defs`-only root, and a root whose `type` contradicts its `properties` —
215
+ * that last one rejected by all nine models, the only rule here with no
216
+ * tolerant provider. Rule (b) also DROPS a `const` or `$ref` carried by the
217
+ * root: measured `root schema is a const` / `root schema is a $ref`, 400
218
+ * even alongside `type: "object"` and `properties`, and for `$ref` even
219
+ * when the target resolves. Nested `const`/`$ref` are meaningful and
220
+ * untouched.
196
221
  *
197
222
  * A root `allOf`'s BRANCHES are deliberately not constrained by (a):
198
223
  * measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar
@@ -201,6 +226,71 @@
201
226
  * all object-carrying is accepted too (xAI infers object-ness from them), so
202
227
  * (b) leaves it alone. Compositions nested below the root are untouched.
203
228
  *
229
+ * 10. Drop an EMPTY array-valued keyword (`allOf`/`anyOf`/`oneOf`/`prefixItems`)
230
+ * at any depth. xAI rejects one anywhere in the tree — `/properties/a/oneOf:
231
+ * [] has less than 1 item` — and it carries no constraint (an `allOf` of
232
+ * nothing is satisfied by everything). Empty OBJECTS (`properties: {}`,
233
+ * `$defs: {}`) and an empty `required: []` are accepted and left alone;
234
+ * `enum: []` and `required: []` were already omitted by transforms #2/#3.
235
+ * 11. Drop a `$ref` whose LOCAL target does not resolve. xAI resolves `#/…`
236
+ * pointers itself and 400s the whole request on a dangling one
237
+ * (`unresolvable $ref '#/$defs/Nope'`), which a third-party MCP server
238
+ * produces easily by shipping a subschema without its definitions — or by
239
+ * putting `$defs` on a nested node, since `#/$defs/X` is anchored at the
240
+ * document ROOT and a nested `$defs` is unaddressable that way. External
241
+ * refs (`https://…`) and resolvable non-`$defs` pointers (`#/properties/a`)
242
+ * are both accepted by xAI and left alone. Only the `$ref` keyword is
243
+ * dropped, so the rest of the node survives.
244
+ *
245
+ * 12. Give any node carrying `properties` an explicit `type: "object"`. Gemini
246
+ * rejects the type-less form outright — "Unable to submit request because
247
+ * `t` functionDeclaration `parameters.b` schema specified incorrect schema
248
+ * type field. For schema with properties, schema type should be OBJECT" —
249
+ * on gemini-3.5/3.6/3.7-flash, and rejects a CONTRADICTORY declared type
250
+ * (`{type:"string", properties:{…}}`) the same way, so the type is
251
+ * overwritten rather than merely defaulted. It is not composition-specific:
252
+ * a plain property subschema and an `items` schema fail identically. xAI
253
+ * and OpenAI accept every one of those forms, which is why this survived
254
+ * until a property sweep over recursive shapes went looking for it. The
255
+ * root is exempt in Gemini's own validator, but the rule is applied
256
+ * uniformly because it costs nothing and one rule beats two.
257
+ *
258
+ * It is resolved during the node's type resolution, not patched onto the
259
+ * finished node, so `dropEnum` sees the real type — an `enum` left on a
260
+ * node that has just become an object would otherwise be dropped by the
261
+ * NEXT pass instead of this one, breaking idempotence. A discarded
262
+ * contradictory type is folded into the description like transform #1's.
263
+ * This also makes a contradictory root-union BRANCH repairable: it arrives
264
+ * at transform #9 already retyped, so the constraint survives instead of
265
+ * the whole keyword being dropped.
266
+ *
267
+ * 13. Drop a `pattern` that uses a regex construct a provider's validator
268
+ * refuses, echoing it into the description instead. Measured: `(?=`, `(?!`,
269
+ * `(?<=`, `(?<!` AND named groups `(?<name>` are rejected by gpt-5.6-sol
270
+ * and gpt-5.6-terra (`Invalid JSON schema: regex lookaround is not
271
+ * supported` — the message says lookaround, but a named group fails
272
+ * identically, so the giveaway is the `(?<` prefix); a BACKREFERENCE
273
+ * (`\1`–`\9`) is rejected by grok-4.5. Non-capturing `(?:` is accepted
274
+ * everywhere and is deliberately NOT caught. This is the defect that broke
275
+ * Monad's own `spaces__invite_member` on every OpenAI model — a whole-
276
+ * request 400 from one tool's email pattern. The scan tracks backslash
277
+ * escaping and character classes, so a literal `\(\?=` or a `[(?=]` class
278
+ * keeps its pattern: dropping a pattern that would have been accepted
279
+ * costs a real constraint on every provider.
280
+ * 14. Inline a ROOT union branch that is a local `$ref`, so the union survives
281
+ * rule (a) instead of being dropped whole. `z.union([A, B])` renders as
282
+ * `{anyOf:[{$ref},{$ref}], $defs}` and xAI does not resolve a `$ref` union
283
+ * branch (0/3 even with `type: "object"` on the root) — so rule (a) used to
284
+ * drop the keyword, which for a root whose properties live entirely in
285
+ * `$defs` left the tool advertising NO parameters. Inlining produces
286
+ * ordinary object branches, accepted 9/9. Only branches that are not
287
+ * already usable are inlined, one level deep (a `$ref` inside the target
288
+ * resolves on its own, which is what makes a recursive definition
289
+ * terminate here), with branch keywords winning over the target's per
290
+ * 2020-12 `$ref` semantics and a cap for the same reason the copy-down has
291
+ * one. A branch whose target is not a plain object schema stays unusable
292
+ * and still falls through to the drop.
293
+ *
204
294
  * When (1) or (2) discards information the model could use — a collapsed
205
295
  * union type, or a wholly-dropped `enum` — that constraint is folded into the
206
296
  * node's `description` as prose ("Accepts string, number, or boolean.",
@@ -174,8 +174,11 @@
174
174
  * `type: "object"` to such a root makes exactly those cases pass 9/9
175
175
  * without touching the composition — so the un-flattenable root `allOf`
176
176
  * 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.
177
+ * discarded, and it stops being the silent 400 it is today. A non-object
178
+ * `type` the root declared for itself IS rewritten to `"object"` and
179
+ * recorded in the description — measured, such a root is rejected by
180
+ * every model. Nested nodes keep their declared types unless transform
181
+ * #12 applies.
179
182
  *
180
183
  * Rule (a) is all-or-nothing per keyword, because ONE unusable branch
181
184
  * poisons the whole union on xAI even when its siblings are good (0/3 with a
@@ -187,12 +190,34 @@
187
190
  * callable and a wrong COMBINATION comes back from dispatch/the remote
188
191
  * server as a recoverable tool error.
189
192
  *
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).
193
+ * A branch's OWN `anyOf`/`oneOf` disqualifies it too, whatever else it
194
+ * declares: `{type:"object", anyOf:[…]}` as a root union branch is 0/3 on
195
+ * Grok, as is a branch carrying both `allOf` and `anyOf` the union is what
196
+ * poisons it. Scope is the branch's own keys and never its subtree: a
197
+ * composition on a branch's PROPERTY is accepted 9/9, one or two levels
198
+ * down, so a subtree scan would destroy constraints every provider honours.
199
+ * A branch's own `allOf` is likewise accepted 9/9 (even with a
200
+ * bare-`required` sub-branch), the same intersection-vs-union asymmetry xAI
201
+ * applies to the root — hence `ROOT_UNION_KEYS`, not `COMPOSITION_KEYS`.
202
+ *
203
+ * A root union of `$ref` branches is no longer a loss — transform #14 below
204
+ * inlines the targets so the union survives. What remains is the narrow
205
+ * residue it cannot inline: a target that is not a plain object schema, one
206
+ * whose expanded size exceeds the copy-down ceiling, and a self-referential
207
+ * root pointer. Those branches stay unusable and still drop the keyword.
208
+ *
209
+ * Rule (b) covers EVERY root, not only composition roots. A property sweep
210
+ * over recursive shapes (`__tests__/tool-schema/property/`) found the
211
+ * narrower gate emitting unusable roots for whole classes the fixtures
212
+ * never reached: a scalar or array root (reachable from transform #1
213
+ * collapsing `{type:["string","null"]}`), an annotation-only root, a
214
+ * `$defs`-only root, and a root whose `type` contradicts its `properties` —
215
+ * that last one rejected by all nine models, the only rule here with no
216
+ * tolerant provider. Rule (b) also DROPS a `const` or `$ref` carried by the
217
+ * root: measured `root schema is a const` / `root schema is a $ref`, 400
218
+ * even alongside `type: "object"` and `properties`, and for `$ref` even
219
+ * when the target resolves. Nested `const`/`$ref` are meaningful and
220
+ * untouched.
196
221
  *
197
222
  * A root `allOf`'s BRANCHES are deliberately not constrained by (a):
198
223
  * measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar
@@ -201,6 +226,71 @@
201
226
  * all object-carrying is accepted too (xAI infers object-ness from them), so
202
227
  * (b) leaves it alone. Compositions nested below the root are untouched.
203
228
  *
229
+ * 10. Drop an EMPTY array-valued keyword (`allOf`/`anyOf`/`oneOf`/`prefixItems`)
230
+ * at any depth. xAI rejects one anywhere in the tree — `/properties/a/oneOf:
231
+ * [] has less than 1 item` — and it carries no constraint (an `allOf` of
232
+ * nothing is satisfied by everything). Empty OBJECTS (`properties: {}`,
233
+ * `$defs: {}`) and an empty `required: []` are accepted and left alone;
234
+ * `enum: []` and `required: []` were already omitted by transforms #2/#3.
235
+ * 11. Drop a `$ref` whose LOCAL target does not resolve. xAI resolves `#/…`
236
+ * pointers itself and 400s the whole request on a dangling one
237
+ * (`unresolvable $ref '#/$defs/Nope'`), which a third-party MCP server
238
+ * produces easily by shipping a subschema without its definitions — or by
239
+ * putting `$defs` on a nested node, since `#/$defs/X` is anchored at the
240
+ * document ROOT and a nested `$defs` is unaddressable that way. External
241
+ * refs (`https://…`) and resolvable non-`$defs` pointers (`#/properties/a`)
242
+ * are both accepted by xAI and left alone. Only the `$ref` keyword is
243
+ * dropped, so the rest of the node survives.
244
+ *
245
+ * 12. Give any node carrying `properties` an explicit `type: "object"`. Gemini
246
+ * rejects the type-less form outright — "Unable to submit request because
247
+ * `t` functionDeclaration `parameters.b` schema specified incorrect schema
248
+ * type field. For schema with properties, schema type should be OBJECT" —
249
+ * on gemini-3.5/3.6/3.7-flash, and rejects a CONTRADICTORY declared type
250
+ * (`{type:"string", properties:{…}}`) the same way, so the type is
251
+ * overwritten rather than merely defaulted. It is not composition-specific:
252
+ * a plain property subschema and an `items` schema fail identically. xAI
253
+ * and OpenAI accept every one of those forms, which is why this survived
254
+ * until a property sweep over recursive shapes went looking for it. The
255
+ * root is exempt in Gemini's own validator, but the rule is applied
256
+ * uniformly because it costs nothing and one rule beats two.
257
+ *
258
+ * It is resolved during the node's type resolution, not patched onto the
259
+ * finished node, so `dropEnum` sees the real type — an `enum` left on a
260
+ * node that has just become an object would otherwise be dropped by the
261
+ * NEXT pass instead of this one, breaking idempotence. A discarded
262
+ * contradictory type is folded into the description like transform #1's.
263
+ * This also makes a contradictory root-union BRANCH repairable: it arrives
264
+ * at transform #9 already retyped, so the constraint survives instead of
265
+ * the whole keyword being dropped.
266
+ *
267
+ * 13. Drop a `pattern` that uses a regex construct a provider's validator
268
+ * refuses, echoing it into the description instead. Measured: `(?=`, `(?!`,
269
+ * `(?<=`, `(?<!` AND named groups `(?<name>` are rejected by gpt-5.6-sol
270
+ * and gpt-5.6-terra (`Invalid JSON schema: regex lookaround is not
271
+ * supported` — the message says lookaround, but a named group fails
272
+ * identically, so the giveaway is the `(?<` prefix); a BACKREFERENCE
273
+ * (`\1`–`\9`) is rejected by grok-4.5. Non-capturing `(?:` is accepted
274
+ * everywhere and is deliberately NOT caught. This is the defect that broke
275
+ * Monad's own `spaces__invite_member` on every OpenAI model — a whole-
276
+ * request 400 from one tool's email pattern. The scan tracks backslash
277
+ * escaping and character classes, so a literal `\(\?=` or a `[(?=]` class
278
+ * keeps its pattern: dropping a pattern that would have been accepted
279
+ * costs a real constraint on every provider.
280
+ * 14. Inline a ROOT union branch that is a local `$ref`, so the union survives
281
+ * rule (a) instead of being dropped whole. `z.union([A, B])` renders as
282
+ * `{anyOf:[{$ref},{$ref}], $defs}` and xAI does not resolve a `$ref` union
283
+ * branch (0/3 even with `type: "object"` on the root) — so rule (a) used to
284
+ * drop the keyword, which for a root whose properties live entirely in
285
+ * `$defs` left the tool advertising NO parameters. Inlining produces
286
+ * ordinary object branches, accepted 9/9. Only branches that are not
287
+ * already usable are inlined, one level deep (a `$ref` inside the target
288
+ * resolves on its own, which is what makes a recursive definition
289
+ * terminate here), with branch keywords winning over the target's per
290
+ * 2020-12 `$ref` semantics and a cap for the same reason the copy-down has
291
+ * one. A branch whose target is not a plain object schema stays unusable
292
+ * and still falls through to the drop.
293
+ *
204
294
  * When (1) or (2) discards information the model could use — a collapsed
205
295
  * union type, or a wholly-dropped `enum` — that constraint is folded into the
206
296
  * node's `description` as prose ("Accepts string, number, or boolean.",
@@ -300,6 +390,109 @@ const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
300
390
  * branches with a handful of `required` names each).
301
391
  */
302
392
  const MAX_ROOT_COPY_DOWN = 512;
393
+ /**
394
+ * Recursive size of a subtree, in nodes and keys, stopping as soon as it exceeds
395
+ * `cap`. Used to price transform #14's inlining: counting only a target's
396
+ * TOP-LEVEL keys is no bound at all, because `{type, properties}` costs 2 no
397
+ * matter how large `properties` is — measured, that let a 0.11 MB schema with
398
+ * 256 `$ref` branches expand to 26 MB of request body, a 242x amplification on
399
+ * attacker-controlled input. Early exit keeps the pricing itself cheap and
400
+ * bounded, and the depth guard mirrors the walk's.
401
+ */
402
+ function subtreeWeight(value, cap, depth = 0) {
403
+ if (depth > MAX_DEPTH)
404
+ return cap + 1;
405
+ if (Array.isArray(value)) {
406
+ let total = 1;
407
+ for (const item of value) {
408
+ total += subtreeWeight(item, cap - total, depth + 1);
409
+ if (total > cap)
410
+ return total;
411
+ }
412
+ return total;
413
+ }
414
+ if (!isPlainObject(value))
415
+ return 1;
416
+ let total = 1;
417
+ for (const item of Object.values(value)) {
418
+ total += 1 + subtreeWeight(item, cap - total, depth + 1);
419
+ if (total > cap)
420
+ return total;
421
+ }
422
+ return total;
423
+ }
424
+ /** Longest `pattern` echoed into a description when transform #13 drops it.
425
+ * A regex is guidance at that point, and an unbounded one would crowd out the
426
+ * rest of the tool's description in the model's context. */
427
+ const MAX_PATTERN_IN_PROSE = 120;
428
+ /**
429
+ * True if a `pattern` uses a regex construct some provider's validator refuses.
430
+ * Measured, one tool per request, `provider.allow_fallbacks: false`:
431
+ *
432
+ * - `(?=`, `(?!`, `(?<=`, `(?<!` and NAMED GROUPS `(?<name>` are all rejected
433
+ * by gpt-5.6-sol and gpt-5.6-terra (`Invalid JSON schema: regex lookaround
434
+ * is not supported`), and accepted by Gemini and Grok. The error says
435
+ * "lookaround", but a named group fails identically — the giveaway is the
436
+ * `(?<` prefix, not the semantics — so all of `(?<` goes.
437
+ * - A BACKREFERENCE (`\1`…`\9`) is rejected by grok-4.5 and accepted by the
438
+ * others.
439
+ * - Non-capturing `(?:` is accepted everywhere and must not be caught here.
440
+ *
441
+ * The scan tracks backslash escaping and character classes so a LITERAL `\(?=`
442
+ * or a `[(?=]` class is not mistaken for the construct — dropping a pattern
443
+ * that would have been accepted costs a real constraint on every provider.
444
+ * Inside a class, `\1` is an octal escape rather than a backreference, so
445
+ * backreference detection is suppressed there too.
446
+ */
447
+ function usesUnsupportedRegexConstruct(pattern) {
448
+ // A deliberately NAIVE character-class scan: `[` opens, the next `]` closes.
449
+ // RFC-correct regex treats a `]` in first position as a literal (`[]a]` is the
450
+ // two-member class `]`,`a`), and an earlier revision of this function did too
451
+ // — but the providers do not, and they are what we are modelling. Measured:
452
+ // `^[(?=]+$` is accepted 5/5 (the `(?=` reads as class content under BOTH
453
+ // parses), while `^[](?=)]+$` is REJECTED by gpt-5.6-sol/terra and accepted by
454
+ // Grok and Gemini. That split is only explicable if OpenAI closes the class at
455
+ // the first `]`, leaving `(?=` outside — i.e. the naive parse. Matching the
456
+ // strict spec here would keep a pattern OpenAI 400s the whole request over.
457
+ let inClass = false;
458
+ for (let i = 0; i < pattern.length; i++) {
459
+ const char = pattern[i];
460
+ if (char === "\\") {
461
+ const next = pattern[i + 1];
462
+ if (!inClass && next !== undefined) {
463
+ // `\1`–`\9` is a backreference (`\0` is NUL), rejected by grok-4.5.
464
+ if (next >= "1" && next <= "9")
465
+ return true;
466
+ // `\k<name>` is a NAMED backreference, rejected by grok-4.5 AND
467
+ // gpt-5.6-sol.
468
+ if (next === "k" && pattern[i + 2] === "<")
469
+ return true;
470
+ }
471
+ i++; // skip the escaped character
472
+ continue;
473
+ }
474
+ if (inClass) {
475
+ if (char === "]")
476
+ inClass = false;
477
+ continue;
478
+ }
479
+ if (char === "[") {
480
+ inClass = true;
481
+ continue;
482
+ }
483
+ if (char === "(" && pattern[i + 1] === "?") {
484
+ const kind = pattern[i + 2];
485
+ // `=`/`!` lookahead and `<` (lookbehind OR named group) are rejected by
486
+ // the OpenAI models; `>` (atomic group) is rejected by grok-4.5. Inline
487
+ // flags `(?i)` and non-capturing `(?:` are accepted everywhere and must
488
+ // NOT be caught.
489
+ if (kind === "=" || kind === "!" || kind === "<" || kind === ">") {
490
+ return true;
491
+ }
492
+ }
493
+ }
494
+ return false;
495
+ }
303
496
  /** Name → subschema maps. `properties` is listed here for completeness, but a
304
497
  * dedicated `key === "properties"` branch in `sanitizeSchemaNode` intercepts it
305
498
  * first (to apply transform #7) and `continue`s — so the generic map handler
@@ -648,6 +841,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
648
841
  // function-declaration schema requires a single `type` and hard-rejects a
649
842
  // type array, which manifests as a misleading downstream error. Computed up
650
843
  // front (not in key order) because the `enum` decision below depends on it.
844
+ const notesFromRetype = [];
651
845
  let typeToEmit = node.type;
652
846
  let emitType = "type" in node;
653
847
  let singleType;
@@ -665,6 +859,30 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
665
859
  else if (typeof node.type === "string") {
666
860
  singleType = node.type;
667
861
  }
862
+ // Transform #12: a node carrying `properties` IS an object, and Gemini
863
+ // requires it to say so — "For schema with properties, schema type should be
864
+ // OBJECT" on all of gemini-3.5/3.6/3.7-flash, both when the `type` is missing
865
+ // and when it contradicts (`{type:"string", properties:{…}}` fails the same
866
+ // way). Not composition-specific: a plain property subschema and an `items`
867
+ // schema fail identically. xAI and OpenAI accept every one of those forms,
868
+ // which is why it survived until a property sweep against live inference went
869
+ // looking.
870
+ //
871
+ // Resolved HERE rather than patched onto the finished node so the rest of the
872
+ // walk sees the real type: `dropEnum` below keys off it, and an `enum` left
873
+ // behind on a node that has just become an object would be dropped by the
874
+ // NEXT pass instead of this one — an idempotence break.
875
+ const retypedFromProperties = isPlainObject(sanitizedProperties) && singleType !== "object";
876
+ if (retypedFromProperties) {
877
+ if (singleType !== undefined) {
878
+ // A contradictory declared type is discarded; record it like transform #1
879
+ // records a collapsed union.
880
+ notesFromRetype.push(`Declared as ${singleType}, but carries properties, so it is treated as an object.`);
881
+ }
882
+ typeToEmit = "object";
883
+ emitType = true;
884
+ singleType = "object";
885
+ }
668
886
  // Gemini accepts `enum` only on string-typed properties. Drop it outright
669
887
  // when the node has an explicit NON-string type (boolean/number/integer/
670
888
  // array/object/null); when the type is "string" or absent, the enum is
@@ -675,7 +893,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
675
893
  // could use. Fold that information into the node's `description` (as prose,
676
894
  // which every provider accepts) so the model still sees it. Computed up
677
895
  // front so it can be appended wherever `description` appears in key order.
678
- const notes = [];
896
+ const notes = [...notesFromRetype];
679
897
  if (Array.isArray(node.type)) {
680
898
  const typeNames = node.type.filter((t) => typeof t === "string");
681
899
  if (typeNames.length > 1) {
@@ -700,6 +918,17 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
700
918
  notes.push(`May also be ${humanJoin(dropped.map(describeEnumValue))}.`);
701
919
  }
702
920
  }
921
+ // Transform #13, decided up front so its note joins the others before
922
+ // `descNote` is frozen and the loop below can simply skip the keyword.
923
+ const dropPattern = typeof node.pattern === "string" &&
924
+ usesUnsupportedRegexConstruct(node.pattern);
925
+ if (dropPattern) {
926
+ const pattern = node.pattern;
927
+ const shown = pattern.length > MAX_PATTERN_IN_PROSE
928
+ ? `${pattern.slice(0, MAX_PATTERN_IN_PROSE)}…`
929
+ : pattern;
930
+ notes.push(`Should match the pattern ${shown}.`);
931
+ }
703
932
  const descNote = notes.join(" ");
704
933
  let descriptionEmitted = false;
705
934
  const out = {};
@@ -752,6 +981,17 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
752
981
  // trip a strict validator) — drop it.
753
982
  continue;
754
983
  }
984
+ if (key === "pattern") {
985
+ // Transform #13: a regex construct some provider refuses takes the WHOLE
986
+ // request down, so the pattern is dropped and echoed as prose instead —
987
+ // the model still sees the intent, and arguments are validated at
988
+ // dispatch and by the remote server regardless. A supported pattern is a
989
+ // real constraint every provider honours and is kept verbatim.
990
+ if (dropPattern)
991
+ continue;
992
+ out.pattern = value;
993
+ continue;
994
+ }
755
995
  if (key === "dependencies") {
756
996
  // draft-07: name → (subschema | string[]). Subschema values are
757
997
  // sanitized (and self-prune their own `required`); string[] values are
@@ -795,6 +1035,14 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
795
1035
  continue;
796
1036
  }
797
1037
  if (COMPOSITION_KEYS.has(key)) {
1038
+ // Transform #10: an EMPTY array-valued keyword is a hard 400 on xAI at
1039
+ // any depth — `/properties/a/oneOf: [] has less than 1 item` — for all
1040
+ // three composition keywords and for `prefixItems` below. It carries no
1041
+ // constraint anyway (an `allOf` of nothing is satisfied by everything), so
1042
+ // it is omitted rather than emitted. Empty OBJECTS (`properties: {}`,
1043
+ // `$defs: {}`) and an empty `required: []` are accepted and unaffected.
1044
+ if (Array.isArray(value) && value.length === 0)
1045
+ continue;
798
1046
  // Transform #8: each element is evaluated against THIS node, so it is
799
1047
  // handed this node's property scope. The self-sufficiency repair is
800
1048
  // applied at the parameters root only (`depth === 0`) — nested
@@ -808,6 +1056,9 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
808
1056
  continue;
809
1057
  }
810
1058
  if (SUBSCHEMA_ARRAY_KEYS.has(key)) {
1059
+ // `prefixItems: []` — same empty-array rejection as the compositions above.
1060
+ if (Array.isArray(value) && value.length === 0)
1061
+ continue;
811
1062
  out[key] = Array.isArray(value)
812
1063
  ? value.map((v) => sanitizeSubschema(v, childDepth))
813
1064
  : value;
@@ -824,6 +1075,11 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
824
1075
  // them to, add one so the dropped information still reaches the model.
825
1076
  if (descNote && !descriptionEmitted)
826
1077
  out.description = descNote;
1078
+ // Transform #12, emission half. A node that DECLARED a `type` had it rewritten
1079
+ // in the key loop above; one that declared none needs the key appended, since
1080
+ // the loop only ever emits keys the node actually had.
1081
+ if (retypedFromProperties && !hasOwn(node, "type"))
1082
+ out.type = "object";
827
1083
  return out;
828
1084
  }
829
1085
  /**
@@ -1003,11 +1259,34 @@ function isUsableRootBranch(branch) {
1003
1259
  return false;
1004
1260
  if (Object.keys(branch).length === 0)
1005
1261
  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.
1262
+ // A branch carrying its OWN `anyOf`/`oneOf` is unusable however object-shaped
1263
+ // it otherwise looks: `{type:"object", anyOf:[…]}` as a root union branch is
1264
+ // 0/3 on grok-4.3/4.5/4.6 (and a branch carrying BOTH `allOf` and `anyOf` is
1265
+ // 0/3 too the union is what poisons it), while every Gemini and GPT model
1266
+ // accepts it. The `type` is not the problem, so the checks below never see it.
1267
+ //
1268
+ // This is NOT the recursion that was rejected earlier. Making such a branch
1269
+ // *usable* is impossible — that was the right call. But "cannot be made
1270
+ // usable" means it must be REPORTED unusable, which routes it into the drop
1271
+ // path that already handles the other unrepairable branch shapes correctly.
1272
+ //
1273
+ // Scope is the branch's OWN keys, never its subtree. A composition on a
1274
+ // branch's PROPERTY is accepted 9/9 (`{type:"object", properties:{a:{anyOf:
1275
+ // […]}}}`), as is one two levels down, so a subtree scan would destroy
1276
+ // constraints every provider honours. `ROOT_UNION_KEYS`, not
1277
+ // `COMPOSITION_KEYS`: a branch's own `allOf` is accepted 9/9 — including with
1278
+ // a bare-`required` sub-branch — which is the same intersection-vs-union
1279
+ // asymmetry xAI applies to the root itself.
1280
+ if (ROOT_UNION_KEYS.some((key) => hasOwn(branch, key)))
1281
+ return false;
1282
+ // A `$ref` disqualifies a branch too, and NOT only when it stands alone:
1283
+ // `{$ref, type:"object"}` and `{$ref, properties}` are both 0/3 on
1284
+ // grok-4.3/4.5/4.6 (accepted by Gemini and OpenAI). xAI does not resolve a
1285
+ // union branch's `$ref` at all, so sibling object keywords do not redeem it.
1286
+ // Transform #14 gets first refusal on these — it inlines the target and keeps
1287
+ // the siblings — so only an un-inlinable one reaches the drop.
1288
+ if (hasOwn(branch, "$ref"))
1289
+ return false;
1011
1290
  // A declared `type` decides on its own: `properties` alongside an explicit
1012
1291
  // NON-object type is a self-contradictory branch (object keywords on a
1013
1292
  // non-object schema), and measured, xAI rejects it exactly as it rejects a
@@ -1027,7 +1306,14 @@ function isUsableRootBranch(branch) {
1027
1306
  * branches) — but a root carrying only `$defs`, or only an `allOf`, is a 400.
1028
1307
  */
1029
1308
  function isObjectRoot(root) {
1030
- if (root.type === "object" || isPlainObject(root.properties))
1309
+ // A declared `type` decides first, and it is the strictest rule measured
1310
+ // anywhere in this module: a root whose `type` is not "object" is rejected by
1311
+ // grok-4.3/4.5/4.6, gpt-5.6-sol AND gemini-3.7-flash — 0/5 — with or without
1312
+ // a `properties` map alongside it. Every other root rule has at least one
1313
+ // tolerant provider; this one has none.
1314
+ if (hasOwn(root, "type"))
1315
+ return root.type === "object";
1316
+ if (isPlainObject(root.properties))
1031
1317
  return true;
1032
1318
  if (Object.keys(root).length === 0)
1033
1319
  return true;
@@ -1059,6 +1345,74 @@ function isObjectRoot(root) {
1059
1345
  * Runs after `flattenRootAllOf`, so a root `allOf` that flattened losslessly is
1060
1346
  * already gone and only an un-flattenable one reaches rule (b).
1061
1347
  */
1348
+ /**
1349
+ * Transform #14: inline a ROOT union branch that is a local `$ref`, so the union
1350
+ * survives rule (a) instead of being dropped whole.
1351
+ *
1352
+ * `z.union([A, B])` renders as `{anyOf: [{$ref}, {$ref}], $defs}`, and xAI does
1353
+ * not resolve a `$ref` union branch — 0/3 even with `type: "object"` on the
1354
+ * root. Rule (a) therefore dropped the keyword and, with the properties living
1355
+ * in `$defs`, the tool was left advertising no parameters at all. Inlining the
1356
+ * target makes the branch an ordinary object branch, which is accepted 9/9, and
1357
+ * keeps the constraint the union was expressing.
1358
+ *
1359
+ * Only branches that are NOT already usable are inlined, so a well-formed union
1360
+ * is untouched and the emitted schema does not grow for no reason. One level
1361
+ * only: a `$ref` inside the inlined target is left to resolve on its own, which
1362
+ * is both what providers accept and what makes a recursive definition
1363
+ * terminate here rather than loop. Sibling keywords on the branch are kept and
1364
+ * win over the target's, matching 2020-12 `$ref` semantics. The branch count is
1365
+ * capped for the same reason the copy-down is — both counts are
1366
+ * attacker-controlled, and the inlined objects are shared references that expand
1367
+ * on `JSON.stringify`.
1368
+ */
1369
+ function inlineRootUnionRefs(root) {
1370
+ let changed = false;
1371
+ const out = {};
1372
+ // ONE budget for the whole root, not one per keyword: a root carrying both an
1373
+ // `anyOf` and a `oneOf` would otherwise get twice the ceiling, which
1374
+ // contradicts the "single ceiling across one root composition" invariant that
1375
+ // transform #8's copy-down also states.
1376
+ let budget = MAX_ROOT_COPY_DOWN;
1377
+ for (const [key, value] of Object.entries(root)) {
1378
+ if (!ROOT_UNION_KEYS.includes(key) || !Array.isArray(value)) {
1379
+ safeSet(out, key, value);
1380
+ continue;
1381
+ }
1382
+ const branches = value.map((branch) => {
1383
+ if (isUsableRootBranch(branch))
1384
+ return branch;
1385
+ if (!isPlainObject(branch) || typeof branch.$ref !== "string")
1386
+ return branch;
1387
+ const ref = branch.$ref;
1388
+ if (ref !== "#" && !ref.startsWith("#/"))
1389
+ return branch;
1390
+ const target = resolveLocalPointer(root, ref);
1391
+ // A self-referential root pointer would inline the whole document into
1392
+ // one of its own branches; nothing useful, and unbounded.
1393
+ if (!isPlainObject(target) || target === root)
1394
+ return branch;
1395
+ // Priced by EXPANDED size — the whole subtree that will be duplicated
1396
+ // into the request body — not by the target's top-level key count.
1397
+ const size = subtreeWeight(target, budget);
1398
+ if (size > budget)
1399
+ return branch;
1400
+ budget -= size;
1401
+ const inlined = {};
1402
+ for (const [k, v] of Object.entries(target))
1403
+ safeSet(inlined, k, v);
1404
+ for (const [k, v] of Object.entries(branch)) {
1405
+ if (k === "$ref")
1406
+ continue;
1407
+ safeSet(inlined, k, v);
1408
+ }
1409
+ changed = true;
1410
+ return inlined;
1411
+ });
1412
+ out[key] = branches;
1413
+ }
1414
+ return changed ? out : root;
1415
+ }
1062
1416
  function dropUnusableRootUnions(root) {
1063
1417
  // All-or-nothing, and deliberately so. ONE unusable branch poisons the whole
1064
1418
  // keyword on xAI — `{type:"object", properties:{…}, oneOf:[{type:"object",
@@ -1094,25 +1448,204 @@ function dropUnusableRootUnions(root) {
1094
1448
  }
1095
1449
  return out;
1096
1450
  }
1451
+ /**
1452
+ * Keywords that make xAI classify the parameters ROOT as something other than an
1453
+ * object schema, whatever else it declares. Measured on grok-4.5: a root
1454
+ * carrying `const` is `root schema is a const` and a root carrying `$ref` is
1455
+ * `root schema is a $ref` — both 400, both even when the root also declares
1456
+ * `type: "object"` AND `properties`, and the `$ref` case even when the target
1457
+ * resolves. Neither can describe a callable argument object, so both are dropped
1458
+ * at the root and their information folded into the description. Nested `const`
1459
+ * and `$ref` are meaningful and untouched.
1460
+ */
1461
+ const ROOT_REJECTED_KEYWORDS = ["const", "$ref"];
1097
1462
  /**
1098
1463
  * Transform #9, rule (b). Runs AFTER `flattenRootAllOf`, so a root `allOf` that
1099
1464
  * 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.
1465
+ * root left bare by rule (a) — reaches this check. Applies to EVERY root, not
1466
+ * just composition roots.
1105
1467
  */
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)) {
1468
+ function ensureObjectRoot(root) {
1469
+ const rejected = ROOT_REJECTED_KEYWORDS.filter((key) => hasOwn(root, key));
1470
+ if (rejected.length > 0) {
1471
+ const stripped = {};
1472
+ for (const [key, value] of Object.entries(root)) {
1473
+ if (rejected.includes(key))
1474
+ continue;
1475
+ safeSet(stripped, key, value);
1476
+ }
1477
+ const note = rejected
1478
+ .map((key) => key === "const"
1479
+ ? `Pinned to a single value (${describeEnumValue(root.const)}) at the schema root.`
1480
+ : `Declared as a reference (${describeEnumValue(root.$ref)}) at the schema root.`)
1481
+ .join(" ");
1482
+ stripped.description =
1483
+ typeof stripped.description === "string" && stripped.description.length > 0
1484
+ ? `${stripped.description} ${note}`
1485
+ : note;
1486
+ return ensureObjectRoot(stripped);
1487
+ }
1488
+ if (isObjectRoot(root))
1489
+ return root;
1490
+ // Applies to EVERY root, not just composition roots. A property-based sweep
1491
+ // over recursive schema shapes found the narrower gate emitting unusable
1492
+ // roots for whole classes the example fixtures never covered: a scalar or
1493
+ // array root (`{type:"string"}`, reachable from transform #1 collapsing
1494
+ // `{type:["string","null"]}`), an annotation-only root (`{description}`), a
1495
+ // `$defs`-only root, and a root whose `type` contradicts its `properties`.
1496
+ // Measured, all of them fail Grok, the scalar ones also fail gpt-5.6-sol, and
1497
+ // the contradictory-type one fails all nine. `{type:"object"}` plus whatever
1498
+ // else the root carried is accepted 9/9 in every case.
1499
+ //
1500
+ // Forcing `type: "object"` over a declared scalar type is a rewrite, not a
1501
+ // repair, and it is the right one: function-call arguments are always a named
1502
+ // object on the wire, so a scalar parameters root cannot describe a callable
1503
+ // tool no matter which provider reads it. Sibling keywords are left as they
1504
+ // are — an `items` stranded on an object root is inert, and every model
1505
+ // accepts it, so removing it would be surgery with no measured benefit.
1506
+ const declaredType = typeof root.type === "string" ? root.type : undefined;
1507
+ const notes = [];
1508
+ const out = { type: "object" };
1509
+ for (const [key, value] of Object.entries(root)) {
1510
+ if (key === "type")
1511
+ continue;
1512
+ // `enum` is the ONE keyword whose treatment depends on the node's type
1513
+ // (transform #2 accepts it only on a string-typed or type-less node), so
1514
+ // retyping the root to "object" while keeping it would leave a root the
1515
+ // node walk would sanitize differently on a second pass — i.e. break
1516
+ // idempotence, which the property sweep catches immediately. Drop it here
1517
+ // and surface the values as prose, exactly as transform #2 does.
1518
+ if (key === "enum") {
1519
+ if (Array.isArray(value) && value.length > 0) {
1520
+ notes.push(`Allowed values: ${value.map(describeEnumValue).join(", ")}.`);
1521
+ }
1522
+ continue;
1523
+ }
1524
+ safeSet(out, key, value);
1525
+ }
1526
+ if (declaredType !== undefined) {
1527
+ // Fold the discarded type into the description, as transforms #1 and #2 do
1528
+ // for the constraints they drop.
1529
+ notes.push(`Declared as ${declaredType} at the schema root; arguments are passed as an object.`);
1530
+ }
1531
+ // `enum` is dropped above whenever the root is retyped, INCLUDING when it
1532
+ // declared no type at all (`{enum:["a"]}`). Keying that on a declared scalar
1533
+ // type left an `enum` sitting on a now-object root, which the next pass would
1534
+ // drop under transform #2 — an idempotence break the generator never reached
1535
+ // because it emitted no type-less `enum` root.
1536
+ if (notes.length > 0) {
1537
+ const note = notes.join(" ");
1538
+ out.description =
1539
+ typeof out.description === "string" && out.description.length > 0
1540
+ ? `${out.description} ${note}`
1541
+ : note;
1542
+ }
1543
+ return out;
1544
+ }
1545
+ /**
1546
+ * Resolve a local JSON Pointer (`#/a/b`) against the document root, returning
1547
+ * `undefined` when any segment is missing. Segment unescaping is the RFC 6901
1548
+ * order — `~1` before `~0` — because doing it the other way turns a literal
1549
+ * `~01` into `/` instead of `~1`.
1550
+ */
1551
+ function resolveLocalPointer(root, ref) {
1552
+ if (ref === "#")
1553
+ return root;
1554
+ const path = ref.slice(2);
1555
+ // `#/` and `#//`-style paths carry no real segments; RFC 6901 makes an empty
1556
+ // path the document root, and treating "" as a property name would prune a
1557
+ // reference that is technically valid.
1558
+ if (path === "")
1110
1559
  return root;
1560
+ const segments = path.split("/");
1561
+ let current = root;
1562
+ for (const raw of segments) {
1563
+ // A `$ref` is a URI, so its fragment is percent-encoded: `#/$defs/a%20b`
1564
+ // addresses the key `"a b"`. Decode that layer FIRST, then the pointer
1565
+ // layer's `~1`/`~0`. Skipping the decode strips a perfectly valid reference
1566
+ // as unresolvable and silently loses the constraint it carried. A malformed
1567
+ // escape (`%zz`) throws, and the raw text is the right fallback there.
1568
+ let decoded;
1569
+ try {
1570
+ decoded = decodeURIComponent(raw);
1571
+ }
1572
+ catch {
1573
+ decoded = raw;
1574
+ }
1575
+ const segment = decoded.replace(/~1/g, "/").replace(/~0/g, "~");
1576
+ if (Array.isArray(current)) {
1577
+ const index = Number(segment);
1578
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
1579
+ return undefined;
1580
+ }
1581
+ current = current[index];
1582
+ continue;
1583
+ }
1584
+ if (!isPlainObject(current) || !hasOwn(current, segment))
1585
+ return undefined;
1586
+ current = current[segment];
1111
1587
  }
1112
- const typed = { type: "object" };
1113
- for (const [key, value] of Object.entries(root))
1114
- safeSet(typed, key, value);
1115
- return typed;
1588
+ return current;
1589
+ }
1590
+ /**
1591
+ * Transform #11: drop a `$ref` whose LOCAL target does not resolve.
1592
+ *
1593
+ * xAI resolves `#/...` pointers itself and 400s the whole request when one
1594
+ * dangles — `unresolvable $ref '#/$defs/Nope': key 'Nope' not found under
1595
+ * '#/$defs'`, and `key '$defs' not found in schema` when the document has no
1596
+ * `$defs` at all. This is easy to produce in practice: a third-party MCP server
1597
+ * that ships a subschema without its definitions, or one whose `$defs` sits on a
1598
+ * nested node (a `#/$defs/X` pointer is anchored at the document ROOT, so a
1599
+ * nested `$defs` is genuinely unaddressable that way).
1600
+ *
1601
+ * Only local pointers are checked. An external `$ref` (`https://…`) is accepted
1602
+ * by xAI and left alone, as is a resolvable non-`$defs` pointer such as
1603
+ * `#/properties/a` — both measured. Dropping only the `$ref` keyword leaves the
1604
+ * rest of the node intact; a node that was nothing but a dangling `$ref` becomes
1605
+ * the unconstrained `{}`, which every model accepts. This runs after the node
1606
+ * walk, so it resolves against the SANITIZED document — the one actually sent.
1607
+ */
1608
+ function pruneUnresolvableRefs(node, root, depth, state = { changed: false }) {
1609
+ // `{}` rather than `true`, matching `sanitizeSchemaNode`'s guard: a boolean
1610
+ // schema is not a plain object, so `canLosslesslyFlattenAllOf` would refuse a
1611
+ // branch it would otherwise merge.
1612
+ if (depth > MAX_DEPTH)
1613
+ return {};
1614
+ if (Array.isArray(node)) {
1615
+ return node.map((item) => pruneUnresolvableRefs(item, root, depth + 1, state));
1616
+ }
1617
+ if (!isPlainObject(node))
1618
+ return node;
1619
+ const out = {};
1620
+ for (const [key, value] of Object.entries(node)) {
1621
+ if (key === "$ref" &&
1622
+ typeof value === "string" &&
1623
+ (value === "#" || value.startsWith("#/")) &&
1624
+ resolveLocalPointer(root, value) === undefined) {
1625
+ state.changed = true;
1626
+ continue;
1627
+ }
1628
+ // Opaque data keywords may legitimately contain a `$ref`-named key that is
1629
+ // not a reference (the walk treats them as data for the same reason), so
1630
+ // they are copied rather than descended into.
1631
+ if (key === "enum" || key === "const" || key === "default" || key === "examples") {
1632
+ safeSet(out, key, value);
1633
+ continue;
1634
+ }
1635
+ safeSet(out, key, pruneUnresolvableRefs(value, root, depth + 1, state));
1636
+ }
1637
+ return out;
1638
+ }
1639
+ /** Prune against `root`, returning the ORIGINAL reference when nothing was
1640
+ * dropped so the pipeline can detect a fixed point without a deep compare
1641
+ * (this module deliberately never hands an untrusted value to
1642
+ * `JSON.stringify`, whose own recursion would outrun the depth guard). */
1643
+ function prunedOrSame(root) {
1644
+ const state = { changed: false };
1645
+ const pruned = pruneUnresolvableRefs(root, root, 0, state);
1646
+ if (!state.changed)
1647
+ return root;
1648
+ return isPlainObject(pruned) ? pruned : {};
1116
1649
  }
1117
1650
  /**
1118
1651
  * Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see
@@ -1129,12 +1662,37 @@ export function sanitizeToolSchema(schema) {
1129
1662
  if (!isPlainObject(schema))
1130
1663
  return {};
1131
1664
  const walked = sanitizeSchemaNode(schema, 0);
1132
- const carriedComposition = [...COMPOSITION_KEYS].some((key) => Array.isArray(walked[key]));
1133
1665
  // Rule (a) runs BEFORE the flatten, not after. `canLosslesslyFlattenAllOf`
1134
1666
  // refuses to merge an `allOf` while a `oneOf`/`anyOf` sibling is present (the
1135
1667
  // root is then not a pure intersection) — so dropping an unusable union
1136
1668
  // afterwards would leave behind an `allOf` that the NEXT pass would flatten,
1137
1669
  // breaking idempotence. Removing the union first makes the root a genuine
1138
1670
  // intersection, which is exactly when flattening it is correct.
1139
- return ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(walked)), carriedComposition);
1671
+ // The root passes are run to a FIXED POINT rather than once.
1672
+ //
1673
+ // Ordering alone cannot satisfy both constraints. Transform #11 has to run
1674
+ // BEFORE the flatten, because dropping a dangling `$ref` can empty the node
1675
+ // that carried it and an emptied `allOf` branch is merge-safe — pruning after
1676
+ // would strand a composition for the NEXT pass to merge. But it also has to
1677
+ // run AFTER the root rewrites, because those remove root keywords a nested
1678
+ // `$ref` may point at: `{properties:{x:{$ref:"#/const"}}, const:{}}` loses its
1679
+ // `const` to transform #9 and is left with exactly the dangling reference #11
1680
+ // exists to prevent, and a flatten can invalidate `#/allOf/0` the same way.
1681
+ //
1682
+ // Iterating settles it: each pass returns its INPUT REFERENCE when it changes
1683
+ // nothing, so a round that touches nothing ends the loop, and the bound keeps
1684
+ // a pathological schema from spinning. Two rounds cover every shape the
1685
+ // property sweep generates; the third is there so the bound is a backstop
1686
+ // rather than the mechanism.
1687
+ let doc = walked;
1688
+ for (let round = 0; round < 3; round++) {
1689
+ const before = doc;
1690
+ // #14 precedes rule (a): an inlinable `$ref` branch becomes an ordinary
1691
+ // object branch, so the union is kept rather than dropped. Anything still
1692
+ // unusable falls through to the drop.
1693
+ doc = ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(inlineRootUnionRefs(prunedOrSame(doc)))));
1694
+ if (doc === before)
1695
+ break;
1696
+ }
1697
+ return doc;
1140
1698
  }