@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.
- package/README.md +30 -0
- package/package.json +1 -1
- package/tools/sanitize-schema.d.ts +164 -19
- package/tools/sanitize-schema.js +926 -53
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": "
|
|
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.
|
|
36
|
-
* `type` and
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
178
|
-
*
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|