@juno-ai/bind 2.0.0 → 4.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.
Files changed (52) hide show
  1. package/README.md +1153 -60
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +31 -7
  5. package/contracts/turn.js +45 -0
  6. package/index.d.ts +16 -5
  7. package/index.js +16 -5
  8. package/loop/index.d.ts +1 -0
  9. package/loop/index.js +1 -0
  10. package/loop/tool-loop.d.ts +260 -0
  11. package/loop/tool-loop.js +276 -0
  12. package/package.json +22 -2
  13. package/plugins/activation.d.ts +67 -0
  14. package/plugins/activation.js +61 -0
  15. package/plugins/index.d.ts +3 -0
  16. package/plugins/index.js +3 -0
  17. package/plugins/registry.d.ts +52 -0
  18. package/plugins/registry.js +54 -0
  19. package/plugins/tool.d.ts +164 -0
  20. package/plugins/tool.js +9 -0
  21. package/routing/billing-basis.d.ts +48 -0
  22. package/routing/billing-basis.js +67 -0
  23. package/routing/circuit-breaker.d.ts +2 -2
  24. package/routing/errors.d.ts +1 -1
  25. package/routing/executor.d.ts +3 -3
  26. package/routing/executor.js +1 -1
  27. package/routing/index.d.ts +11 -9
  28. package/routing/index.js +11 -9
  29. package/routing/plan-degradation.d.ts +34 -0
  30. package/routing/plan-degradation.js +38 -0
  31. package/routing/plan.d.ts +2 -2
  32. package/routing/planner.d.ts +4 -4
  33. package/routing/planner.js +1 -1
  34. package/routing/policy.d.ts +1 -1
  35. package/routing/policy.js +1 -1
  36. package/routing/transport.d.ts +2 -2
  37. package/run/children.d.ts +204 -0
  38. package/run/children.js +226 -0
  39. package/run/harness.d.ts +94 -0
  40. package/run/harness.js +140 -0
  41. package/run/index.d.ts +3 -0
  42. package/run/index.js +3 -0
  43. package/run/tool-batch.d.ts +16 -0
  44. package/run/tool-batch.js +83 -0
  45. package/tools/index.d.ts +1 -0
  46. package/tools/index.js +1 -0
  47. package/tools/sanitize-schema.d.ts +150 -0
  48. package/tools/sanitize-schema.js +683 -0
  49. package/transcript/index.d.ts +1 -0
  50. package/transcript/index.js +1 -0
  51. package/transcript/validate.d.ts +54 -0
  52. package/transcript/validate.js +226 -0
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Sanitize a tool's JSON Schema before it is handed to the model.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * Tool schemas reach the LLM verbatim — for MCP tools the third-party
7
+ * server's `inputSchema` is forwarded as-is (`rawJsonSchema` in
8
+ * `buildToolDefinitions`). Some providers validate that schema strictly
9
+ * and reject the *entire* request (every tool, not just the bad one) on
10
+ * the first violation. The one that bit us in production was Google
11
+ * Gemini via OpenRouter:
12
+ *
13
+ * OpenRouter 400 — Provider: Google (Google AI Studio), INVALID_ARGUMENT:
14
+ * GenerateContentRequest.tools[0].function_declarations[50]
15
+ * .parameters.properties[issue_fields].items.required[0]:
16
+ * property is not defined
17
+ *
18
+ * That message is a *symptom*, not the cause. The GitHub MCP `issue_write`
19
+ * tool's `issue_fields.items` schema contained constructs Gemini's
20
+ * function-declaration schema (a strict OpenAPI 3.0 subset) does not
21
+ * support: a property typed with a `type` ARRAY (`value: { type:
22
+ * ["string","number","boolean"] }`) and an `enum` on a boolean property
23
+ * (`delete: { type: "boolean", enum: [true] }`). When Gemini's translator
24
+ * hits an unsupported construct it drops the enclosing `properties` object,
25
+ * after which the (perfectly valid) `required: ["field_name"]` dangles —
26
+ * hence the misleading "property is not defined". The root causes were
27
+ * confirmed empirically by bisecting the real schema against live Gemini
28
+ * inference via OpenRouter; OpenAI and Anthropic accept all of it.
29
+ *
30
+ * What it does (applied to EVERY tool for EVERY model)
31
+ * ----------------------------------------------------
32
+ * The transforms only remove or normalize constructs that carry no real
33
+ * constraint for a model's tool call. Each was verified against live Gemini:
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.
42
+ * 2. Constrain `enum` to Gemini's rule: it is accepted ONLY as a list of
43
+ * strings on a string-typed (or type-less) property. So drop `enum`
44
+ * entirely when the node has an explicit non-string type (boolean,
45
+ * number, integer, array, object, null), and otherwise filter it to its
46
+ * string members (dropping any non-string leftovers — e.g. after a union
47
+ * `type` collapsed to "string") and omit it if none remain. `const`,
48
+ * which Gemini DOES support on every type, is left untouched — do not
49
+ * convert it to `enum`, which would manufacture the rejected case.
50
+ * 3. Drop `required` entries with no matching key in the SAME node's own
51
+ * `properties` map (evaluated per node, against direct `properties`
52
+ * only — Gemini does not resolve composition or parent scope, and a
53
+ * `required` naming an absent property is a hard 400). A genuinely
54
+ * dangling `required` is invalid everywhere; this is a safety net, not
55
+ * the primary fix for the issue above.
56
+ * 4. Strip the `$schema` dialect declaration, which function-calling APIs
57
+ * ignore. We deliberately KEEP `$id`: for schemas that use relative
58
+ * `$ref`s it defines the base URI / identifies subschemas, and we
59
+ * preserve `$ref`, so dropping `$id` could break reference resolution.
60
+ * 5. Flatten a top-level `allOf` of object subschemas into a single object
61
+ * schema (merge `properties`, union `required`). A function's parameters
62
+ * root produced by a Zod intersection (`a.and(b)`) renders as `{ allOf:
63
+ * [ {type:object…}, {type:object…} ] }` with no root `type`/`properties`.
64
+ * xAI (Grok) via OpenRouter strictly requires the parameters ROOT to be a
65
+ * plain object schema and rejects the whole request with a 502 "Invalid
66
+ * arguments passed to the model" on a composition root; Gemini tolerates
67
+ * it. Merging is safe for tool calling — `allOf` means "satisfy every
68
+ * branch", i.e. an object carrying the union of all branches' properties.
69
+ * Confirmed empirically against live Grok (`document__convert_excel` /
70
+ * `document__ocr`). Only `allOf` is flattened (a true intersection);
71
+ * `anyOf`/`oneOf` roots are left untouched. The flatten is applied ONLY
72
+ * when it's lossless — every branch is a plain object schema with
73
+ * merge-safe keywords; a branch carrying `$ref`, `not`, `if`, nested
74
+ * composition, a non-object `type`, etc. leaves the `allOf` intact rather
75
+ * than dropping the unmerged constraint.
76
+ * 6. Drop a boolean `additionalProperties: false`. xAI (Grok) via OpenRouter
77
+ * rejects that form on a NESTED object schema with a hard 400 ("property
78
+ * schema 'false' is not supported"), failing the WHOLE request (every
79
+ * tool). The constraint it carries — "no keys beyond those declared" — is
80
+ * advisory for tool calling (arguments are validated at dispatch against
81
+ * the tool's zod schema and by the remote MCP server), so it is dropped
82
+ * rather than allowed to sink the request. `additionalProperties: true`
83
+ * (the JSON Schema default, a no-op) is left as-is, and an OBJECT-valued
84
+ * `additionalProperties` is a real subschema constraint every provider
85
+ * accepts — it is kept and recursed into. Confirmed against the production
86
+ * failure (Notion `notion-create-pages`,
87
+ * `pages[].properties.properties.additionalProperties`).
88
+ * 7. Neutralize a tool parameter literally NAMED `properties` whose schema is
89
+ * object-shaped. xAI (Grok) via OpenRouter mis-reads such a field as the
90
+ * JSON-Schema `properties` keyword — it descends in, injects an
91
+ * `additionalProperties: false`, then rejects its own injection, failing the
92
+ * WHOLE request with `/properties/properties/additionalProperties: property
93
+ * schema 'false' is not supported` (so transform #6 alone never helped —
94
+ * the rejected `false` is xAI's own, not ours). Notion's `notion-create-pages`
95
+ * / `update-page` carry exactly this field (the page's `properties` map), so
96
+ * it 400s the default Grok agent. xAI accepts the field only when it does NOT
97
+ * look like an object schema, so we collapse an object-shaped
98
+ * `properties`-named node to annotations only (`description` / `title`).
99
+ * Advisory-safe (args validated at dispatch + by the remote server; Notion's
100
+ * `properties` is an open per-database map with no fixed sub-schema), and the
101
+ * key name is never changed so the model still emits the right argument key.
102
+ * Confirmed live: `x-ai/grok-4.3` 400→200, Gemini/Claude unaffected.
103
+ *
104
+ * When (1) or (2) discards information the model could use — a collapsed
105
+ * union type, or a wholly-dropped `enum` — that constraint is folded into the
106
+ * node's `description` as prose ("Accepts string, number, or boolean.",
107
+ * "Allowed values: true.") so the model still sees it. `description` is a free
108
+ * string every provider accepts, so this is always safe.
109
+ *
110
+ * It deliberately leaves meaningful validation keywords (`format`,
111
+ * `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
112
+ * `enum`, length/range bounds, `$ref`, `$id`) intact so compliant providers
113
+ * keep their guidance. The two structural exceptions are both xAI-rejected: the
114
+ * boolean `false` form of `additionalProperties` (transform #6), and the
115
+ * object-structure of a property literally named `properties` (transform #7),
116
+ * which is collapsed to annotations only.
117
+ *
118
+ * The walk is keyword-aware: it only recurses into positions that JSON
119
+ * Schema defines as subschemas, and treats `enum` / `const` / `default` /
120
+ * `examples` values (and the `required` array itself) as opaque data. That
121
+ * way a tool parameter literally named `properties` or `required`, or an
122
+ * example value that happens to contain those keys, is never mistaken for
123
+ * schema structure and corrupted by OUR walk. (Transform #7 separately
124
+ * rewrites a `properties`-named object field — not because we confuse it, but
125
+ * because xAI's parser does.)
126
+ *
127
+ * Robustness: the walk is depth-bounded (`MAX_DEPTH`) so a pathologically
128
+ * nested third-party schema can't overflow the stack, and the entry point
129
+ * tolerates a non-object root (a boolean schema, or a malformed server's
130
+ * payload) by returning an empty object instead of throwing. Past the bound
131
+ * the subtree is *replaced*, not returned verbatim — a bomb passed through
132
+ * would simply overflow one frame later, when the caller serializes the
133
+ * result into a request body. For the same reason nothing in this module
134
+ * hands an untrusted value to `JSON.stringify`, which recurses on its own and
135
+ * so would reintroduce the unbounded walk the depth guard exists to prevent.
136
+ *
137
+ * Non-mutating, but NOT a full deep clone: the returned object has a fresh
138
+ * spine (every object/array node the walk descends into is rebuilt), so the
139
+ * input `rawJsonSchema` — shared across runs — is never mutated. Opaque
140
+ * leaf values (`enum`, `const`, `default`, `examples`, `pattern`, …) are
141
+ * carried over by reference, so callers must treat the result as read-only.
142
+ */
143
+ /** Hard cap on recursion depth to bound stack usage on adversarial input. */
144
+ const MAX_DEPTH = 100;
145
+ /** Metadata keyword(s) the function-calling APIs ignore; safe to drop. */
146
+ const METADATA_KEYS = new Set(["$schema"]);
147
+ /**
148
+ * Keys skipped at the keyword level — none are valid JSON Schema keywords.
149
+ * Only `__proto__` carries real danger (its setter mutates the prototype
150
+ * rather than creating an own property; `safeSet` uses `defineProperty` for
151
+ * it inside maps); `constructor`/`prototype` are included conservatively.
152
+ */
153
+ const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
154
+ /** Single-subschema keywords (value is a subschema, or a boolean).
155
+ * `additionalProperties` is handled by a dedicated branch in `sanitizeSchemaNode`
156
+ * (transform #6 drops its boolean `false` form and recurses into the object
157
+ * form), so it is intentionally NOT listed here — that dedicated branch
158
+ * `continue`s before the generic single-subschema handler is reached. */
159
+ const SUBSCHEMA_SINGLE_KEYS = new Set([
160
+ "unevaluatedProperties",
161
+ "additionalItems",
162
+ "unevaluatedItems",
163
+ "contains",
164
+ "propertyNames",
165
+ "not",
166
+ "if",
167
+ "then",
168
+ "else",
169
+ ]);
170
+ /** Array-of-subschema keywords. */
171
+ const SUBSCHEMA_ARRAY_KEYS = new Set([
172
+ "allOf",
173
+ "anyOf",
174
+ "oneOf",
175
+ "prefixItems",
176
+ ]);
177
+ /** Name → subschema maps. `properties` is listed here for completeness, but a
178
+ * dedicated `key === "properties"` branch in `sanitizeSchemaNode` intercepts it
179
+ * first (to apply transform #7) and `continue`s — so the generic map handler
180
+ * only ever sees the others. Keep it in the set so that if the dedicated branch
181
+ * is ever removed, `properties` still falls back to generic map sanitization
182
+ * rather than being treated as opaque. */
183
+ const SUBSCHEMA_MAP_KEYS = new Set([
184
+ "properties",
185
+ "patternProperties",
186
+ "$defs",
187
+ "definitions",
188
+ "dependentSchemas",
189
+ ]);
190
+ function isPlainObject(value) {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+ /** Join words as a human list: ["a"]→"a", ["a","b"]→"a or b", ["a","b","c"]→"a, b, or c". */
194
+ function humanJoin(items) {
195
+ if (items.length <= 1)
196
+ return items.join("");
197
+ if (items.length === 2)
198
+ return `${items[0]} or ${items[1]}`;
199
+ return `${items.slice(0, -1).join(", ")}, or ${items[items.length - 1]}`;
200
+ }
201
+ /**
202
+ * Render one `enum` member for the human-readable prose we attach to a
203
+ * description when the enum itself is dropped.
204
+ *
205
+ * Composite members are summarized rather than serialized. `JSON.stringify`
206
+ * recurses over the value, so a third party could hand us a deeply nested
207
+ * `enum` member and overflow the stack here — outside the walk's depth guard,
208
+ * which bounds structural recursion only. The summary loses nothing a model
209
+ * could act on: an enum of nested containers is not a constraint prose can
210
+ * usefully express.
211
+ */
212
+ function describeEnumValue(value) {
213
+ if (typeof value === "object" && value !== null) {
214
+ return Array.isArray(value) ? "[…]" : "{…}";
215
+ }
216
+ // `undefined` (illegal in JSON but reachable from a hand-built object)
217
+ // stringifies to `undefined`, not a string.
218
+ return JSON.stringify(value) ?? String(value);
219
+ }
220
+ /**
221
+ * True if a (sanitized) schema node "looks like an object schema" to xAI's
222
+ * tool-schema parser — i.e. carries `type: "object"` or any of the keywords it
223
+ * descends through as object structure (`properties` / `patternProperties` /
224
+ * `additionalProperties`). Used only by the transform-#7 collision check below.
225
+ */
226
+ function looksObjectShapedToXai(node) {
227
+ return (node.type === "object" ||
228
+ "properties" in node ||
229
+ "patternProperties" in node ||
230
+ "additionalProperties" in node);
231
+ }
232
+ /**
233
+ * Transform #7 (xAI/Grok workaround). A tool parameter literally NAMED
234
+ * `properties` whose schema is object-shaped makes xAI's validator mis-read it
235
+ * as the JSON-Schema `properties` keyword: it descends in, injects an
236
+ * `additionalProperties: false`, then rejects its own injection — failing the
237
+ * WHOLE request with `/properties/properties/additionalProperties: property
238
+ * schema 'false' is not supported` (verified live against `x-ai/grok-4.3`).
239
+ * Notion's `notion-create-pages` / `update-page` tools carry exactly such a
240
+ * field (the page's `properties` map), so this 400s the default Grok agent.
241
+ *
242
+ * xAI accepts the field only when it does NOT look like an object schema
243
+ * (`{ description }`, `{}`, or a non-object `type` all pass). So we collapse an
244
+ * object-shaped `properties`-named node to its non-structural keywords: the
245
+ * `description` / `title` annotations plus the opaque data keywords `default` /
246
+ * `examples` / `const` (verified live that xAI does NOT descend into these — it
247
+ * treats them as data, not schema — so they preserve model guidance without
248
+ * re-triggering the bug). Only the object-STRUCTURE keywords (`type: "object"`,
249
+ * `properties`, `patternProperties`, `additionalProperties`, `required`, …) are
250
+ * dropped. This is advisory-safe — arguments are passed through and validated at
251
+ * dispatch + by the remote MCP server — and the field's object intent survives in
252
+ * its description (Notion's `properties` is an open per-database map with no fixed
253
+ * sub-schema, so little is lost). The key name is never changed, so the model
254
+ * still emits the correct argument key.
255
+ */
256
+ function neutralizeXaiPropertiesField(node) {
257
+ const out = {};
258
+ if (typeof node.title === "string")
259
+ out.title = node.title;
260
+ out.description =
261
+ typeof node.description === "string" && node.description.length > 0
262
+ ? node.description
263
+ : "An object.";
264
+ // Opaque data keywords (not schema structure) — kept as model guidance; xAI
265
+ // does not descend into them, so they don't re-trigger the collision.
266
+ for (const k of ["default", "examples", "const"]) {
267
+ if (k in node)
268
+ out[k] = node[k];
269
+ }
270
+ return out;
271
+ }
272
+ /** Assign a key/value, using `defineProperty` for `__proto__` so a literal
273
+ * `__proto__` key becomes an own property instead of mutating the prototype. */
274
+ function safeSet(out, key, value) {
275
+ if (key === "__proto__") {
276
+ Object.defineProperty(out, key, {
277
+ value,
278
+ enumerable: true,
279
+ writable: true,
280
+ configurable: true,
281
+ });
282
+ }
283
+ else {
284
+ out[key] = value;
285
+ }
286
+ }
287
+ /** Recurse into a value that JSON Schema defines as a single subschema. In
288
+ * draft-07 `items` / tuple forms a value may also be an array of subschemas. */
289
+ function sanitizeSubschema(value, depth) {
290
+ // Arrays count toward the depth budget too. Counting only object nodes left
291
+ // the bound bypassable by a chain of nested arrays (`items: [[[[…]]]]`),
292
+ // which recurses here without ever reaching `sanitizeSchemaNode` — a third
293
+ // party could overflow the stack with a schema the guard claimed to bound.
294
+ //
295
+ // `true` — the accept-anything boolean schema — rather than the value: past
296
+ // MAX_DEPTH the input is adversarial, and returning it would only move the
297
+ // overflow to whoever serializes our output.
298
+ if (depth > MAX_DEPTH)
299
+ return true;
300
+ if (Array.isArray(value)) {
301
+ return value.map((v) => sanitizeSubschema(v, depth + 1));
302
+ }
303
+ if (isPlainObject(value))
304
+ return sanitizeSchemaNode(value, depth);
305
+ return value; // boolean or other primitive (e.g. `not: false`)
306
+ }
307
+ /** Recurse into a name → subschema map (`properties`, `$defs`, …). */
308
+ function sanitizeSchemaMap(value, depth) {
309
+ if (!isPlainObject(value))
310
+ return value;
311
+ const out = {};
312
+ for (const [name, sub] of Object.entries(value)) {
313
+ safeSet(out, name, isPlainObject(sub) || Array.isArray(sub)
314
+ ? sanitizeSubschema(sub, depth)
315
+ : sub);
316
+ }
317
+ return out;
318
+ }
319
+ function sanitizeSchemaNode(node, depth) {
320
+ // Depth guard: stop walking absurdly nested input rather than overflowing
321
+ // the stack. Real tool schemas are a few levels deep; anything past
322
+ // MAX_DEPTH is adversarial, so the subtree is replaced with the
323
+ // unconstrained empty schema instead of being carried through — passing it
324
+ // through bounds only OUR recursion, and the bomb would then detonate in
325
+ // `JSON.stringify` when the sanitized schema is serialized into the request.
326
+ if (depth > MAX_DEPTH)
327
+ return {};
328
+ const childDepth = depth + 1;
329
+ // Property names a `required` on THIS node may reference — the node's OWN
330
+ // `properties` only (see module header: matches Gemini's validator, which
331
+ // does not resolve composition or parent scope).
332
+ const propertyNames = isPlainObject(node.properties)
333
+ ? new Set(Object.keys(node.properties))
334
+ : null;
335
+ // Resolve the node's effective single `type`, collapsing a JSON Schema
336
+ // `type` ARRAY (a union, e.g. `["string","number","boolean"]` or a nullable
337
+ // `["string","null"]`) to the first non-"null" member. Gemini's
338
+ // function-declaration schema requires a single `type` and hard-rejects a
339
+ // type array, which manifests as a misleading downstream error. Computed up
340
+ // front (not in key order) because the `enum` decision below depends on it.
341
+ let typeToEmit = node.type;
342
+ let emitType = "type" in node;
343
+ let singleType;
344
+ if (Array.isArray(node.type)) {
345
+ const nonNull = node.type.filter((t) => t !== "null");
346
+ if (nonNull.length > 0) {
347
+ typeToEmit = nonNull[0];
348
+ if (typeof nonNull[0] === "string")
349
+ singleType = nonNull[0];
350
+ }
351
+ else {
352
+ emitType = false; // a `["null"]`-only type → drop it
353
+ }
354
+ }
355
+ else if (typeof node.type === "string") {
356
+ singleType = node.type;
357
+ }
358
+ // Gemini accepts `enum` only on string-typed properties. Drop it outright
359
+ // when the node has an explicit NON-string type (boolean/number/integer/
360
+ // array/object/null); when the type is "string" or absent, the enum is
361
+ // instead filtered to its string members below. (The constraint is advisory
362
+ // — arguments are validated at dispatch and by the remote server.)
363
+ const dropEnum = singleType !== undefined && singleType !== "string";
364
+ // A `type` collapse or a dropped `enum` discards a constraint the model
365
+ // could use. Fold that information into the node's `description` (as prose,
366
+ // which every provider accepts) so the model still sees it. Computed up
367
+ // front so it can be appended wherever `description` appears in key order.
368
+ const notes = [];
369
+ if (Array.isArray(node.type)) {
370
+ const typeNames = node.type.filter((t) => typeof t === "string");
371
+ if (typeNames.length > 1) {
372
+ notes.push(`Accepts ${humanJoin(typeNames)}.`);
373
+ }
374
+ else if (typeNames.length === 1 && typeNames[0] === "null") {
375
+ // A `["null"]`-only type was dropped (no scalar type to emit).
376
+ notes.push("Must be null.");
377
+ }
378
+ }
379
+ if (Array.isArray(node.enum)) {
380
+ const stringMembers = node.enum.filter((v) => typeof v === "string");
381
+ if (dropEnum || stringMembers.length === 0) {
382
+ // The whole enum is dropped (non-string type, or no string members
383
+ // survive the string-filter) — surface every original allowed value.
384
+ notes.push(`Allowed values: ${node.enum.map(describeEnumValue).join(", ")}.`);
385
+ }
386
+ else if (node.enum.length > stringMembers.length) {
387
+ // Partial filter: string members are kept in the emitted `enum`; surface
388
+ // the dropped non-string members so they aren't lost.
389
+ const dropped = node.enum.filter((v) => typeof v !== "string");
390
+ notes.push(`May also be ${humanJoin(dropped.map(describeEnumValue))}.`);
391
+ }
392
+ }
393
+ const descNote = notes.join(" ");
394
+ let descriptionEmitted = false;
395
+ const out = {};
396
+ for (const [key, value] of Object.entries(node)) {
397
+ if (METADATA_KEYS.has(key))
398
+ continue;
399
+ // Never let an invalid `__proto__`/`constructor`/`prototype` *keyword*
400
+ // through; none are valid JSON Schema keywords. (Literal property names
401
+ // by those names are handled safely inside `sanitizeSchemaMap`.)
402
+ if (PROTO_KEYS.has(key))
403
+ continue;
404
+ if (key === "type") {
405
+ if (emitType)
406
+ out.type = typeToEmit;
407
+ continue;
408
+ }
409
+ if (key === "description") {
410
+ // Append any constraint notes (collapsed type / dropped enum) to the
411
+ // existing description so the information survives the sanitization.
412
+ descriptionEmitted = true;
413
+ out.description =
414
+ typeof value === "string" && descNote
415
+ ? `${value} ${descNote}`
416
+ : value;
417
+ continue;
418
+ }
419
+ if (key === "enum") {
420
+ // Gemini accepts `enum` ONLY as a list of strings: an enum on a
421
+ // boolean/number/integer type is rejected (`dropEnum`), and even on a
422
+ // string type any non-string member is rejected — including leftovers
423
+ // after a union `type` was collapsed to "string" (e.g. `type:
424
+ // ["string","number"], enum: ["a", 1]` → keep only `["a"]`). Filter to
425
+ // string members; drop the keyword entirely if none remain. (Verified
426
+ // against live Gemini inference.)
427
+ if (!dropEnum && Array.isArray(value)) {
428
+ const strings = value.filter((v) => typeof v === "string");
429
+ if (strings.length > 0)
430
+ out.enum = strings;
431
+ }
432
+ continue;
433
+ }
434
+ if (key === "required") {
435
+ if (Array.isArray(value)) {
436
+ const pruned = value.filter((name) => typeof name === "string" && (propertyNames?.has(name) ?? false));
437
+ // An empty `required: []` is valid but noise; omit it entirely.
438
+ if (pruned.length > 0)
439
+ out.required = pruned;
440
+ }
441
+ // A non-array `required` is malformed JSON Schema (and would itself
442
+ // trip a strict validator) — drop it.
443
+ continue;
444
+ }
445
+ if (key === "dependencies") {
446
+ // draft-07: name → (subschema | string[]). Subschema values are
447
+ // sanitized (and self-prune their own `required`); string[] values are
448
+ // property dependencies (opaque data), left untouched.
449
+ if (isPlainObject(value)) {
450
+ const deps = {};
451
+ for (const [name, dep] of Object.entries(value)) {
452
+ safeSet(deps, name, isPlainObject(dep) ? sanitizeSchemaNode(dep, childDepth) : dep);
453
+ }
454
+ out.dependencies = deps;
455
+ }
456
+ else {
457
+ out.dependencies = value;
458
+ }
459
+ continue;
460
+ }
461
+ if (key === "properties") {
462
+ // Recurse normally, then apply transform #7: a child property literally
463
+ // named `properties` whose schema is object-shaped trips xAI's parser, so
464
+ // collapse it to annotations only. (Only the `properties` MAP triggers the
465
+ // `/properties/properties` collision — `patternProperties`/`$defs` keys are
466
+ // patterns/definition names, not property names, so they go through the
467
+ // generic map handler below.)
468
+ const mapped = sanitizeSchemaMap(value, childDepth);
469
+ if (isPlainObject(mapped) &&
470
+ isPlainObject(mapped.properties) &&
471
+ looksObjectShapedToXai(mapped.properties)) {
472
+ mapped.properties = neutralizeXaiPropertiesField(mapped.properties);
473
+ }
474
+ out.properties = mapped;
475
+ continue;
476
+ }
477
+ if (SUBSCHEMA_MAP_KEYS.has(key)) {
478
+ out[key] = sanitizeSchemaMap(value, childDepth);
479
+ continue;
480
+ }
481
+ if (key === "additionalProperties") {
482
+ // Drop the boolean `false` form — xAI (Grok) rejects it on a nested
483
+ // object schema and 400s the whole request (transform #6). `true` (no-op
484
+ // default) and an object-valued subschema (a real constraint) are kept;
485
+ // the latter is recursed into like any other single subschema.
486
+ if (value === false)
487
+ continue;
488
+ out.additionalProperties = sanitizeSubschema(value, childDepth);
489
+ continue;
490
+ }
491
+ if (SUBSCHEMA_SINGLE_KEYS.has(key) || key === "items") {
492
+ // `items` is a subschema (2020-12) or an array of subschemas (draft-07
493
+ // tuple); `sanitizeSubschema` handles both.
494
+ out[key] = sanitizeSubschema(value, childDepth);
495
+ continue;
496
+ }
497
+ if (SUBSCHEMA_ARRAY_KEYS.has(key)) {
498
+ out[key] = Array.isArray(value)
499
+ ? value.map((v) => sanitizeSubschema(v, childDepth))
500
+ : value;
501
+ continue;
502
+ }
503
+ // Opaque keyword (`default`, `examples`, `format`, `pattern`,
504
+ // `dependentRequired`, numeric bounds, …) — copy verbatim. Notably we do
505
+ // NOT walk into `default`/`examples` values, which are arbitrary data and
506
+ // may legitimately contain `properties`/`required` keys that are not
507
+ // schema structure.
508
+ safeSet(out, key, value);
509
+ }
510
+ // If we have constraint notes but the node had no `description` to append
511
+ // them to, add one so the dropped information still reaches the model.
512
+ if (descNote && !descriptionEmitted)
513
+ out.description = descNote;
514
+ return out;
515
+ }
516
+ /**
517
+ * Keys an `allOf` branch may carry and still be merged losslessly into a single
518
+ * object schema: plain object structure plus annotations. A branch with any
519
+ * other keyword (`$ref`, `not`, `if`/`then`/`else`, `anyOf`/`oneOf`, nested
520
+ * `allOf`, `dependentSchemas`/`dependencies`, `patternProperties`,
521
+ * `propertyNames`, `unevaluatedProperties`, …) carries a constraint the flatten
522
+ * does NOT carry forward, so flattening it would silently drop that constraint.
523
+ */
524
+ const MERGEABLE_ALLOF_BRANCH_KEYS = new Set([
525
+ "type",
526
+ "properties",
527
+ "required",
528
+ "additionalProperties",
529
+ "description",
530
+ "title",
531
+ ]);
532
+ /** Sibling keywords on the root that, alongside `allOf`, mean the root is not a
533
+ * pure intersection — flattening only the `allOf` would change its meaning. */
534
+ const ROOT_COMPOSITION_SIBLINGS = [
535
+ "anyOf",
536
+ "oneOf",
537
+ "not",
538
+ "if",
539
+ "then",
540
+ "else",
541
+ "$ref",
542
+ ];
543
+ /** True if every `allOf` branch is a plain object schema whose keywords are all
544
+ * merge-safe (and whose `type`, if present, is `"object"`; whose
545
+ * `additionalProperties`, if present, is `true` — the only boolean surviving
546
+ * transform #6; an object-valued form blocks the flatten) — i.e. the
547
+ * intersection can be flattened without dropping any constraint. */
548
+ function canLosslesslyFlattenAllOf(root, branches) {
549
+ if (ROOT_COMPOSITION_SIBLINGS.some((k) => k in root))
550
+ return false;
551
+ for (const branch of branches) {
552
+ if (!isPlainObject(branch))
553
+ return false;
554
+ for (const key of Object.keys(branch)) {
555
+ if (!MERGEABLE_ALLOF_BRANCH_KEYS.has(key))
556
+ return false;
557
+ }
558
+ if ("type" in branch && branch.type !== "object")
559
+ return false;
560
+ if ("additionalProperties" in branch &&
561
+ typeof branch.additionalProperties !== "boolean") {
562
+ return false;
563
+ }
564
+ }
565
+ return true;
566
+ }
567
+ /**
568
+ * Flatten a top-level `allOf` of object subschemas into a single object schema
569
+ * (see module-header transform #5). Operates ONLY on the parameters root —
570
+ * `allOf` nested deeper is a meaningful constraint left intact by the walk. The
571
+ * root node and every `allOf` branch contribute their `properties` (a later
572
+ * branch wins a key collision) and `required` entries (unioned, then pruned to
573
+ * the merged property set). The node walk runs BEFORE this flatten and has
574
+ * already dropped every boolean `additionalProperties: false` (transform #6),
575
+ * so only `true` can reach the merge — it is carried through and the flattened
576
+ * root never emits `false`. Branch-level `description`s are folded into the
577
+ * merged description rather than dropped.
578
+ *
579
+ * The flatten is applied ONLY when every branch can be merged losslessly
580
+ * (`canLosslesslyFlattenAllOf`): a branch carrying a keyword the merge doesn't
581
+ * carry forward — `$ref`, `not`, `if`, nested composition, `dependentSchemas`,
582
+ * `patternProperties`, … — or a non-object `type`, or a schema-valued
583
+ * `additionalProperties`, leaves the `allOf` UNTOUCHED. Dropping such a branch
584
+ * would advertise an empty/under-constrained object to the model (it runs on
585
+ * every third-party MCP `rawJsonSchema`), so we keep the original composition
586
+ * rather than silently lose the constraint. A no-op when the root has no
587
+ * `allOf` array.
588
+ */
589
+ function flattenRootAllOf(root) {
590
+ if (!Array.isArray(root.allOf))
591
+ return root;
592
+ // Only flatten when nothing is lost; otherwise keep the composition intact.
593
+ if (!canLosslesslyFlattenAllOf(root, root.allOf))
594
+ return root;
595
+ const mergedProps = {};
596
+ const requiredSet = new Set();
597
+ // Transform #6 already stripped every boolean `additionalProperties: false`
598
+ // during the node walk, so the only boolean that can reach here is `true`
599
+ // (the no-op default). Carry it through if any branch declares it; we never
600
+ // (re)introduce a `false` the providers downstream would reject.
601
+ let additionalProperties;
602
+ // Branch-level `description`s are folded into the merged description rather
603
+ // than dropped (a branch may carry input-semantics context for the model).
604
+ const branchDescriptions = [];
605
+ // The root's own object fields merge first, then each allOf branch in order.
606
+ const branches = [root, ...root.allOf];
607
+ for (const branch of branches) {
608
+ if (!isPlainObject(branch))
609
+ continue;
610
+ if (isPlainObject(branch.properties)) {
611
+ for (const [name, sub] of Object.entries(branch.properties)) {
612
+ safeSet(mergedProps, name, sub);
613
+ }
614
+ }
615
+ if (Array.isArray(branch.required)) {
616
+ for (const name of branch.required) {
617
+ if (typeof name === "string")
618
+ requiredSet.add(name);
619
+ }
620
+ }
621
+ // Only `true` survives the node walk (transform #6 dropped `false`); carry
622
+ // it. An object-valued form would have blocked the flatten entirely
623
+ // (canLosslesslyFlattenAllOf), so it can't appear here.
624
+ if (branch.additionalProperties === true)
625
+ additionalProperties = true;
626
+ // Collect branch descriptions other than the root's own (handled below).
627
+ if (branch !== root && typeof branch.description === "string") {
628
+ branchDescriptions.push(branch.description);
629
+ }
630
+ }
631
+ // Preserve root-level annotations (e.g. `description`, `$id`) while dropping
632
+ // the composition keyword and any root object fields now folded into the
633
+ // merged result.
634
+ const out = {};
635
+ for (const [key, value] of Object.entries(root)) {
636
+ if (key === "allOf" ||
637
+ key === "properties" ||
638
+ key === "required" ||
639
+ key === "type" ||
640
+ key === "additionalProperties") {
641
+ continue;
642
+ }
643
+ safeSet(out, key, value);
644
+ }
645
+ out.type = "object";
646
+ out.properties = mergedProps;
647
+ // Fold branch descriptions into the merged description (root's first), keeping
648
+ // only those not already present so a duplicated annotation isn't repeated.
649
+ const descriptions = [
650
+ ...(typeof out.description === "string" ? [out.description] : []),
651
+ ...branchDescriptions,
652
+ ];
653
+ // `Set`, not `filter`+`indexOf`: the latter is O(n²), and `allOf` branch
654
+ // count is attacker-controlled for an MCP tool schema — 40k branches cost
655
+ // ~1.9s of blocked event loop, which is a denial of service for every tenant
656
+ // on the process, not just the one whose connector served the schema.
657
+ // Insertion order is preserved either way, so the output is unchanged.
658
+ const uniqueDescriptions = [...new Set(descriptions)];
659
+ if (uniqueDescriptions.length > 0)
660
+ out.description = uniqueDescriptions.join(" ");
661
+ // `hasOwnProperty.call`, not `name in mergedProps`: the latter would treat a
662
+ // required entry named after a prototype member ("toString", "constructor")
663
+ // as present and fail to prune a genuinely-undeclared property.
664
+ const required = [...requiredSet].filter((name) => Object.prototype.hasOwnProperty.call(mergedProps, name));
665
+ if (required.length > 0)
666
+ out.required = required;
667
+ if (additionalProperties !== undefined) {
668
+ out.additionalProperties = additionalProperties;
669
+ }
670
+ return out;
671
+ }
672
+ /**
673
+ * Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see
674
+ * the module header for the immutability caveat — opaque leaf values are
675
+ * shared by reference, so treat the result as read-only). A non-object root
676
+ * (boolean schema, or malformed third-party payload) yields an empty object
677
+ * rather than throwing.
678
+ */
679
+ export function sanitizeToolSchema(schema) {
680
+ if (!isPlainObject(schema))
681
+ return {};
682
+ return flattenRootAllOf(sanitizeSchemaNode(schema, 0));
683
+ }