@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.
- package/README.md +1153 -60
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +31 -7
- package/contracts/turn.js +45 -0
- package/index.d.ts +16 -5
- package/index.js +16 -5
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +276 -0
- package/package.json +22 -2
- package/plugins/activation.d.ts +67 -0
- package/plugins/activation.js +61 -0
- package/plugins/index.d.ts +3 -0
- package/plugins/index.js +3 -0
- package/plugins/registry.d.ts +52 -0
- package/plugins/registry.js +54 -0
- package/plugins/tool.d.ts +164 -0
- package/plugins/tool.js +9 -0
- package/routing/billing-basis.d.ts +48 -0
- package/routing/billing-basis.js +67 -0
- package/routing/circuit-breaker.d.ts +2 -2
- package/routing/errors.d.ts +1 -1
- package/routing/executor.d.ts +3 -3
- package/routing/executor.js +1 -1
- package/routing/index.d.ts +11 -9
- package/routing/index.js +11 -9
- package/routing/plan-degradation.d.ts +34 -0
- package/routing/plan-degradation.js +38 -0
- package/routing/plan.d.ts +2 -2
- package/routing/planner.d.ts +4 -4
- package/routing/planner.js +1 -1
- package/routing/policy.d.ts +1 -1
- package/routing/policy.js +1 -1
- package/routing/transport.d.ts +2 -2
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/harness.d.ts +94 -0
- package/run/harness.js +140 -0
- package/run/index.d.ts +3 -0
- package/run/index.js +3 -0
- package/run/tool-batch.d.ts +16 -0
- package/run/tool-batch.js +83 -0
- package/tools/index.d.ts +1 -0
- package/tools/index.js +1 -0
- package/tools/sanitize-schema.d.ts +150 -0
- package/tools/sanitize-schema.js +683 -0
- package/transcript/index.d.ts +1 -0
- package/transcript/index.js +1 -0
- package/transcript/validate.d.ts +54 -0
- 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
|
+
}
|