@juno-ai/bind 13.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.
package/README.md CHANGED
@@ -56,6 +56,27 @@ constraints that will fail CI if you break them.
56
56
 
57
57
  **Breaking**
58
58
 
59
+ - **`sanitizeToolSchema` now emits a union for a nullable property** instead of
60
+ collapsing it. `{type:["string","null"], minLength:1}` comes out as
61
+ `{anyOf:[{type:"string",minLength:1},{type:"null"}]}`, with the node's
62
+ type-bearing keywords on the typed branch and its annotations left outside;
63
+ the old output was `{type:"string", minLength:1}` plus an "Accepts string or
64
+ null." note on the description. Nothing in the API changed, but the emitted
65
+ schema did — a consumer asserting on sanitizer output, or reading `.type` off
66
+ a sanitized node, needs updating.
67
+
68
+ The collapse was lossy in a way that broke callers. A model handed a
69
+ type-satisfying schema and a description that says "or send null" cannot
70
+ express "none" in the half of the declaration it treats as binding, so it
71
+ invents a value: one tool parameter authored `type:["string","null"],
72
+ minLength:1` received `"/"`, `". "` and `".000001"` for thousands of calls,
73
+ each refused by the downstream API and each retried. The union shape is
74
+ measured accepted on grok-4.3/4.5/4.6, gemini-3.5/3.6/3.7-flash and
75
+ gpt-5.6-terra/sol/luna. A genuine multi-type union
76
+ (`["string","number","boolean"]`) still collapses — no single shape is
77
+ accepted by every provider — and so does a nullable type at the parameters root, in
78
+ a branch of a root `anyOf`/`oneOf`, or in a composition branch whose
79
+ `required` resolves against the enclosing node.
59
80
  - `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
60
81
  of `void`. A caller that ignores the return value is unchanged, but a
61
82
  wrapper *annotated* `Promise<void>` no longer typechecks — widen it to
@@ -1048,6 +1069,15 @@ property; a boolean `additionalProperties: false` on a nested object; and a
1048
1069
  parameter literally named `properties`. Run it on third-party (e.g. MCP) tool
1049
1070
  schemas too — those are where the violations usually come from.
1050
1071
 
1072
+ One transform is worth knowing about even if no provider forced it. A **nullable
1073
+ property** — `{type:["string","null"], minLength:1}` — is rewritten to
1074
+ `{anyOf:[{type:"string",minLength:1},{type:"null"}]}` rather than reduced to its
1075
+ non-null type with a note on the description. Reducing it is what a strict
1076
+ provider needs, but it leaves the model unable to say "none" in the part of the
1077
+ declaration it treats as binding, so it invents a value that satisfies the type:
1078
+ `"/"`, `" "`, `"null"`, `"undefined"`. If you author nullable parameters, you do
1079
+ not need to hand-write the union — the sanitizer produces it.
1080
+
1051
1081
  ### How to repair a transcript before sending it
1052
1082
 
1053
1083
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "13.0.0",
3
+ "version": "14.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",
@@ -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,
@@ -202,9 +253,11 @@
202
253
  *
203
254
  * A root union of `$ref` branches is no longer a loss — transform #14 below
204
255
  * 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.
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.
208
261
  *
209
262
  * Rule (b) covers EVERY root, not only composition roots. A property sweep
210
263
  * over recursive shapes (`__tests__/tool-schema/property/`) found the
@@ -291,11 +344,13 @@
291
344
  * one. A branch whose target is not a plain object schema stays unusable
292
345
  * and still falls through to the drop.
293
346
  *
294
- * When (1) or (2) discards information the model could use — a collapsed
295
- * union type, or a wholly-dropped `enum` — that constraint is folded into the
296
- * node's `description` as prose ("Accepts string, number, or boolean.",
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.",
297
350
  * "Allowed values: true.") so the model still sees it. `description` is a free
298
- * 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).
299
354
  *
300
355
  * It deliberately leaves meaningful validation keywords (`format`,
301
356
  * `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
@@ -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,
@@ -202,9 +253,11 @@
202
253
  *
203
254
  * A root union of `$ref` branches is no longer a loss — transform #14 below
204
255
  * 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.
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.
208
261
  *
209
262
  * Rule (b) covers EVERY root, not only composition roots. A property sweep
210
263
  * over recursive shapes (`__tests__/tool-schema/property/`) found the
@@ -291,11 +344,13 @@
291
344
  * one. A branch whose target is not a plain object schema stays unusable
292
345
  * and still falls through to the drop.
293
346
  *
294
- * When (1) or (2) discards information the model could use — a collapsed
295
- * union type, or a wholly-dropped `enum` — that constraint is folded into the
296
- * node's `description` as prose ("Accepts string, number, or boolean.",
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.",
297
350
  * "Allowed values: true.") so the model still sees it. `description` is a free
298
- * 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).
299
354
  *
300
355
  * It deliberately leaves meaningful validation keywords (`format`,
301
356
  * `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
@@ -370,6 +425,93 @@ const SUBSCHEMA_ARRAY_KEYS = new Set([
370
425
  * `prefixItems` is in `SUBSCHEMA_ARRAY_KEYS` but NOT here: its elements are the
371
426
  * item schemas of a tuple, a fresh scope, not branches of the enclosing object. */
372
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
+ ]);
373
515
  /**
374
516
  * Ceiling on how many property subschemas transform #8 may copy down across ALL
375
517
  * branches of one root composition.
@@ -786,20 +928,154 @@ function sanitizeCompositionBranch(branch, depth, parentProperties, budget) {
786
928
  // Arrays and boolean/primitive schemas carry no `required` to resolve.
787
929
  if (!isPlainObject(branch))
788
930
  return sanitizeSubschema(branch, depth);
789
- const sanitized = sanitizeSchemaNode(branch, depth, parentProperties);
790
931
  // A non-null budget marks a ROOT composition — the only place the
791
- // 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);
792
935
  return budget
793
936
  ? makeBranchSelfSufficient(sanitized, parentProperties, budget)
794
937
  : sanitized;
795
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
+ }
796
1067
  /**
797
1068
  * @param parentProperties When this node is a composition branch, the enclosing
798
1069
  * node's SANITIZED `properties` map — the extra scope its `required` may name
799
1070
  * (transform #8). `undefined`/`null` for every other node, which keeps
800
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`.
801
1077
  */
802
- function sanitizeSchemaNode(node, depth, parentProperties) {
1078
+ function sanitizeSchemaNode(node, depth, parentProperties, isRootCompositionBranch = false) {
803
1079
  // Depth guard: stop walking absurdly nested input rather than overflowing
804
1080
  // the stack. Real tool schemas are a few levels deep; anything past
805
1081
  // MAX_DEPTH is adversarial, so the subtree is replaced with the
@@ -809,10 +1085,27 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
809
1085
  if (depth > MAX_DEPTH)
810
1086
  return {};
811
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);
812
1103
  // Sanitized `properties`, computed ahead of the key walk because transform #8
813
1104
  // needs it before the walk reaches whichever of `properties` / `oneOf` comes
814
1105
  // first in key order. Emitted verbatim when the walk reaches `properties`.
815
- 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")
816
1109
  ? sanitizePropertiesKeyword(node.properties, childDepth)
817
1110
  : undefined;
818
1111
  const ownProperties = isPlainObject(sanitizedProperties)
@@ -836,16 +1129,25 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
836
1129
  const declaresProperty = (name) => (ownProperties !== null && hasOwn(ownProperties, name)) ||
837
1130
  (parentProperties != null && hasOwn(parentProperties, name));
838
1131
  // Resolve the node's effective single `type`, collapsing a JSON Schema
839
- // `type` ARRAY (a union, e.g. `["string","number","boolean"]` or a nullable
840
- // `["string","null"]`) to the first non-"null" member. Gemini's
841
- // function-declaration schema requires a single `type` and hard-rejects a
842
- // type array, which manifests as a misleading downstream error. Computed up
843
- // 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.
844
1140
  const notesFromRetype = [];
845
1141
  let typeToEmit = node.type;
846
1142
  let emitType = "type" in node;
847
1143
  let singleType;
848
- 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)) {
849
1151
  const nonNull = node.type.filter((t) => t !== "null");
850
1152
  if (nonNull.length > 0) {
851
1153
  typeToEmit = nonNull[0];
@@ -894,7 +1196,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
894
1196
  // which every provider accepts) so the model still sees it. Computed up
895
1197
  // front so it can be appended wherever `description` appears in key order.
896
1198
  const notes = [...notesFromRetype];
897
- if (Array.isArray(node.type)) {
1199
+ if (nullableType === null && Array.isArray(node.type)) {
898
1200
  const typeNames = node.type.filter((t) => typeof t === "string");
899
1201
  if (typeNames.length > 1) {
900
1202
  notes.push(`Accepts ${humanJoin(typeNames)}.`);
@@ -904,7 +1206,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
904
1206
  notes.push("Must be null.");
905
1207
  }
906
1208
  }
907
- if (Array.isArray(node.enum)) {
1209
+ if (nullableType === null && Array.isArray(node.enum)) {
908
1210
  const stringMembers = node.enum.filter((v) => typeof v === "string");
909
1211
  if (dropEnum || stringMembers.length === 0) {
910
1212
  // The whole enum is dropped (non-string type, or no string members
@@ -920,7 +1222,8 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
920
1222
  }
921
1223
  // Transform #13, decided up front so its note joins the others before
922
1224
  // `descNote` is frozen and the loop below can simply skip the keyword.
923
- const dropPattern = typeof node.pattern === "string" &&
1225
+ const dropPattern = nullableType === null &&
1226
+ typeof node.pattern === "string" &&
924
1227
  usesUnsupportedRegexConstruct(node.pattern);
925
1228
  if (dropPattern) {
926
1229
  const pattern = node.pattern;
@@ -940,7 +1243,19 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
940
1243
  // by those names are handled safely inside `sanitizeSchemaMap`.)
941
1244
  if (PROTO_KEYS.has(key))
942
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;
943
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
+ }
944
1259
  if (emitType)
945
1260
  out.type = typeToEmit;
946
1261
  continue;