@cubicecho/agent-core 2.23.0 → 2.25.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 (71) hide show
  1. package/README.md +14 -9
  2. package/dist/agent-loop.d.ts +84 -204
  3. package/dist/agent-loop.js +416 -643
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -22
  6. package/dist/capabilities.d.ts +140 -35
  7. package/dist/capabilities.js +345 -152
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +210 -78
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +163 -93
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +25 -17
  15. package/dist/continuation.js +70 -47
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +108 -58
  21. package/dist/events.js +265 -128
  22. package/dist/guards.d.ts +53 -0
  23. package/dist/guards.js +55 -0
  24. package/dist/hook-events.d.ts +38 -0
  25. package/dist/hook-events.js +36 -0
  26. package/dist/hooks.d.ts +105 -83
  27. package/dist/hooks.js +224 -139
  28. package/dist/index.d.ts +30 -25
  29. package/dist/index.js +29 -23
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +40 -143
  41. package/dist/retry.js +79 -285
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +44 -16
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +51 -11
  49. package/dist/schema-compat.js +375 -193
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +127 -39
  53. package/dist/side-task.js +189 -100
  54. package/dist/snapshot.d.ts +12 -7
  55. package/dist/snapshot.js +87 -65
  56. package/dist/spec.d.ts +66 -36
  57. package/dist/spec.js +514 -265
  58. package/dist/stream.d.ts +73 -16
  59. package/dist/stream.js +228 -98
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +96 -46
  62. package/dist/tokens.d.ts +171 -6
  63. package/dist/tokens.js +326 -6
  64. package/dist/tool-calls.d.ts +89 -15
  65. package/dist/tool-calls.js +205 -85
  66. package/dist/tool-loading.d.ts +109 -187
  67. package/dist/tool-loading.js +286 -372
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +87 -43
  71. package/package.json +4 -4
@@ -1,138 +1,222 @@
1
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1
+ import { getOrCreate, isRecord } from './guards.js';
2
+ import { FUNCTION_TOOL, SchemaType } from './wire.js';
2
3
  /** Lookahead and lookbehind: `(?=`, `(?!`, `(?<=`, `(?<!`. */
3
4
  const LOOKAROUND = /\(\?<?[=!]/;
4
- const PRIMITIVES = new Set(["object", "string", "number", "integer", "boolean", "array", "null"]);
5
- const EMPTY_OBJECT = () => ({ type: "object", properties: {} });
5
+ const PRIMITIVES = new Set(Object.values(SchemaType));
6
+ /**
7
+ * An object schema with no properties: what a position that holds no usable schema becomes.
8
+ *
9
+ * @returns A new object on every call, so no two callers share one.
10
+ */
11
+ const EMPTY_OBJECT = () => ({ type: SchemaType.Object, properties: {} });
6
12
  /** Keys whose value is a schema, or a list of them — several are spelled both ways. */
7
13
  const SCHEMA_KEYS = new Set([
8
- "items",
9
- "additionalProperties",
10
- "not",
11
- "if",
12
- "then",
13
- "else",
14
- "contains",
15
- "propertyNames",
16
- "anyOf",
17
- "oneOf",
18
- "allOf",
19
- "prefixItems",
14
+ 'items',
15
+ 'additionalProperties',
16
+ 'not',
17
+ 'if',
18
+ 'then',
19
+ 'else',
20
+ 'contains',
21
+ 'propertyNames',
22
+ 'anyOf',
23
+ 'oneOf',
24
+ 'allOf',
25
+ 'prefixItems',
20
26
  ]);
21
27
  /** Keys whose value is a name -> schema map. */
22
- const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
28
+ const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions']);
29
+ /** The two spellings of a schema's own pool of named definitions. */
30
+ const POOL_KEYS = ['definitions', '$defs'];
31
+ /**
32
+ * One keyword's value with `fn` applied wherever it holds a schema, and untouched where it holds
33
+ * data — which is what keeps a walk out of `default`, `enum` and `const`.
34
+ *
35
+ * @param key - The keyword the value sits under, which is what says whether it holds schemas.
36
+ * @param value - What the keyword holds. Not mutated.
37
+ * @param fn - The rewrite for one schema position. Handed whatever is there, schema or not.
38
+ * @returns A new array or map of what `fn` gave back, or `fn`'s answer for a single schema. For
39
+ * any other keyword, and for a map keyword that holds no map, `value` itself.
40
+ */
41
+ const mapChildren = (key, value, fn) => {
42
+ if (SCHEMA_KEYS.has(key)) {
43
+ return Array.isArray(value) ? value.map((sub) => fn(sub)) : fn(value);
44
+ }
45
+ // The keys here are argument names; only the values are schemas.
46
+ if (SCHEMA_MAPS.has(key) && isRecord(value)) {
47
+ return Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, fn(sub)]));
48
+ }
49
+ return value;
50
+ };
23
51
  /**
24
- * Coerces one schema position. Malformed MCP output sometimes puts a bare type name where a
25
- * whole schema belongs, which the grammar converter reports as `Unrecognized schema: "object"`.
52
+ * The names a schema's `required` lists, without whatever in it is not a name.
53
+ *
54
+ * @param node - The schema whose `required` is read.
55
+ * @returns A new array, empty where `required` is missing or is not a list.
56
+ */
57
+ const requiredOf = (node) => Array.isArray(node.required) ? node.required.filter((name) => typeof name === 'string') : [];
58
+ /**
59
+ * Coerces one schema position.
60
+ *
61
+ * @param node - Whatever sits where a schema belongs. Not mutated.
62
+ * @returns A boolean schema as it is, and an object normalised into a new one. A bare primitive
63
+ * type name becomes `{ type }`; `"object"`, any other string and anything else at all become an
64
+ * object with no properties.
65
+ *
66
+ * @remarks
67
+ * Malformed MCP output sometimes puts a bare type name where a whole schema belongs, which the
68
+ * grammar converter reports as `Unrecognized schema: "object"`.
26
69
  */
27
70
  function asSchema(node) {
28
- if (typeof node === "string")
29
- return PRIMITIVES.has(node) && node !== "object" ? { type: node } : EMPTY_OBJECT();
30
- if (typeof node === "boolean")
71
+ if (typeof node === 'string') {
72
+ return PRIMITIVES.has(node) && node !== SchemaType.Object ? { type: node } : EMPTY_OBJECT();
73
+ }
74
+ if (typeof node === 'boolean') {
31
75
  return node;
32
- if (!isObject(node))
76
+ }
77
+ if (!isRecord(node)) {
33
78
  return EMPTY_OBJECT();
79
+ }
34
80
  return normalize(node);
35
81
  }
36
- /** Recursively rewrites the shapes llama.cpp's grammar converter cannot represent. */
82
+ /**
83
+ * Recursively rewrites the shapes llama.cpp's grammar converter cannot represent.
84
+ *
85
+ * @param node - The schema as written. Not mutated.
86
+ * @returns A new schema, rewritten at every schema position under it. Where it holds a `$ref`,
87
+ * the reference alone.
88
+ */
37
89
  function normalize(node) {
38
- const out = {};
90
+ const built = {};
39
91
  for (const [key, value] of Object.entries(node)) {
40
92
  // `type: ["string", "null"]` — the converter only accepts a single string type.
41
- if (key === "type" && Array.isArray(value)) {
42
- const names = value.filter((item) => typeof item === "string");
43
- const concrete = names.filter((name) => name !== "null");
44
- if (names.includes("null"))
45
- out.nullable = true;
46
- if (concrete.length === 1)
47
- out.type = concrete[0];
48
- else if (concrete.length > 1)
49
- out.anyOf = concrete.map((name) => ({ type: name }));
50
- else
51
- out.type = "null";
52
- }
53
- else if (SCHEMA_KEYS.has(key)) {
54
- out[key] = Array.isArray(value) ? value.map(asSchema) : asSchema(value);
55
- }
56
- else if (SCHEMA_MAPS.has(key) && isObject(value)) {
57
- out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, asSchema(sub)]));
93
+ if (key === 'type' && Array.isArray(value)) {
94
+ const names = value.filter((item) => typeof item === 'string');
95
+ const concrete = names.filter((name) => name !== SchemaType.Null);
96
+ if (names.includes(SchemaType.Null)) {
97
+ built.nullable = true;
98
+ }
99
+ if (concrete.length === 1) {
100
+ built.type = concrete[0];
101
+ }
102
+ else if (concrete.length > 1) {
103
+ built.anyOf = concrete.map((name) => ({ type: name }));
104
+ }
105
+ else {
106
+ built.type = SchemaType.Null;
107
+ }
58
108
  }
59
109
  else {
60
- out[key] = value;
110
+ built[key] = mapChildren(key, value, asSchema);
61
111
  }
62
112
  }
63
- collapseNullableUnion(out);
113
+ const out = collapseNullableUnion(built);
64
114
  // A grammar is context-free; lookaround is not expressible in one at all, so no converter
65
115
  // can accept it. Dropping it costs one advisory constraint on one string field.
66
- if (typeof out.pattern === "string" && LOOKAROUND.test(out.pattern))
116
+ if (typeof out.pattern === 'string' && LOOKAROUND.test(out.pattern)) {
67
117
  delete out.pattern;
118
+ }
68
119
  // `{"type": "object"}` with no properties produces invalid GBNF.
69
- if (out.type === "object" && !isObject(out.properties))
120
+ if (out.type === SchemaType.Object && !isRecord(out.properties)) {
70
121
  out.properties = {};
122
+ }
71
123
  // Strict validators reject any sibling of `$ref`, and draft-07 ignores them, so a reference
72
124
  // stands alone or not at all. This is not hypothetical tidying: collapsing `anyOf: [{$ref},
73
125
  // {type: "null"}]` — the shape a schema-generated server emits at every optional argument —
74
126
  // lands `nullable` right next to the `$ref` that survived. Whatever is dropped here was
75
127
  // already unreadable to a conforming consumer; optionality still lives in the parent's
76
128
  // `required`.
77
- if ("$ref" in out)
129
+ if ('$ref' in out) {
78
130
  return { $ref: out.$ref };
131
+ }
79
132
  return out;
80
133
  }
81
134
  /**
82
135
  * `{anyOf: [{type: "string"}, {type: "null"}]}` is how Pydantic-backed MCP servers spell an
83
- * optional field. Optionality already lives in the parent's `required`, so keep the one real
84
- * branch. A union with two real branches is meaningful and is left alone.
136
+ * optional field.
137
+ *
138
+ * @param node - The schema whose `anyOf` and `oneOf` are looked at. Not mutated.
139
+ * @returns `node` itself unless a union is one real branch beside `null` ones. Then a copy without
140
+ * that union, marked `nullable`, with the real branch's keywords laid over its own.
141
+ *
142
+ * @remarks
143
+ * Optionality already lives in the parent's `required`, so keep the one real branch. A union with
144
+ * two real branches is meaningful and is left alone.
85
145
  */
86
146
  function collapseNullableUnion(node) {
87
- for (const key of ["anyOf", "oneOf"]) {
88
- const variants = node[key];
89
- if (!Array.isArray(variants))
147
+ let out = node;
148
+ for (const key of ['anyOf', 'oneOf']) {
149
+ const variants = out[key];
150
+ if (!Array.isArray(variants)) {
90
151
  continue;
91
- const concrete = variants.filter((item) => !(isObject(item) && item.type === "null"));
92
- if (concrete.length !== 1 || concrete.length === variants.length)
152
+ }
153
+ const concrete = variants.filter((item) => !(isRecord(item) && item.type === SchemaType.Null));
154
+ if (concrete.length !== 1 || concrete.length === variants.length) {
93
155
  continue;
94
- delete node[key];
95
- Object.assign(node, { nullable: true, ...(isObject(concrete[0]) ? concrete[0] : {}) });
156
+ }
157
+ const { [key]: _collapsed, ...rest } = out;
158
+ out = { ...rest, nullable: true, ...(isRecord(concrete[0]) ? concrete[0] : {}) };
96
159
  }
160
+ return out;
97
161
  }
98
162
  /** Combinators at the top level of a parameters schema; strict backends reject them outright. */
99
- const TOP_LEVEL_COMBINATORS = ["allOf", "anyOf", "oneOf", "enum", "not"];
163
+ const TOP_LEVEL_COMBINATORS = ['allOf', 'anyOf', 'oneOf', 'enum', 'not'];
100
164
  /** `#/definitions/Args` or `#/$defs/Args` — a pointer into this schema's own definitions. */
101
165
  const LOCAL_POINTER = /^#\/(definitions|\$defs)\/([^/]+)$/;
102
- /** The `definitions` and `$defs` that a local pointer in this schema resolves against. */
166
+ /**
167
+ * The `definitions` and `$defs` that a local pointer in this schema resolves against.
168
+ *
169
+ * @param parameters - The root schema, which is where the pools live.
170
+ * @returns A new object holding whichever of the two the root has as an object — the pools
171
+ * themselves, not copies. Empty where it has neither.
172
+ */
103
173
  function poolsOf(parameters) {
104
174
  const defs = {};
105
- for (const key of ["definitions", "$defs"])
106
- if (isObject(parameters[key]))
175
+ for (const key of POOL_KEYS) {
176
+ if (isRecord(parameters[key])) {
107
177
  defs[key] = parameters[key];
178
+ }
179
+ }
108
180
  return defs;
109
181
  }
110
182
  /**
111
183
  * Follows a chain of local references to the schema it arrives at, or `undefined` where it
112
184
  * arrives at none — a pointer into another document, one that comes back around to itself, or a
113
- * name the pools do not hold. A node that is not a reference resolves to itself, so a caller can
114
- * hand this a branch without first asking which spelling it is.
185
+ * name the pools do not hold.
186
+ *
187
+ * @param node - The schema position to resolve, reference or not.
188
+ * @param defs - The pools to resolve against, as `poolsOf` collects them from the root.
189
+ * @returns The schema arrived at — the object itself, not a copy — or `undefined`, which is also
190
+ * the answer where the chain ends on something that is not an object.
115
191
  *
116
- * @param node The schema position to resolve, reference or not.
117
- * @param defs The pools to resolve against, as `poolsOf` collects them from the root.
192
+ * @remarks
193
+ * An object that is not a reference resolves to itself, so a caller can hand this a branch without
194
+ * first asking which spelling it is.
118
195
  */
119
196
  function resolveRef(node, defs) {
120
197
  const seen = new Set();
121
198
  let current = node;
122
- while (isObject(current) && typeof current.$ref === "string") {
199
+ while (isRecord(current) && typeof current.$ref === 'string') {
123
200
  const pointer = current.$ref;
124
- const target = LOCAL_POINTER.exec(pointer);
125
- if (!target || seen.has(pointer))
201
+ const [, poolKey, name] = LOCAL_POINTER.exec(pointer) ?? [];
202
+ if (!name || seen.has(pointer)) {
126
203
  return undefined;
204
+ }
127
205
  seen.add(pointer);
128
- const pool = defs[target[1]];
129
- current = isObject(pool) ? pool[target[2]] : undefined;
206
+ const pool = defs[poolKey];
207
+ current = isRecord(pool) ? pool[name] : undefined;
130
208
  }
131
- return isObject(current) ? current : undefined;
209
+ return isRecord(current) ? current : undefined;
132
210
  }
133
211
  /**
134
212
  * Replaces a root-level `$ref` with what it points at.
135
213
  *
214
+ * @param parameters - The root schema. Not mutated.
215
+ * @returns `parameters` itself where its `$ref` is not a string. Otherwise a new object: what the
216
+ * pointer lands on with the root's pools laid over it, or an object with no properties where it
217
+ * lands nowhere.
218
+ *
219
+ * @remarks
136
220
  * Dropping the siblings of a `$ref` is right at a nested position and wrong at this one: the
137
221
  * siblings here are the `definitions` the pointer needs, so the reference is left dangling and
138
222
  * `properties` is then backfilled empty below. The tool goes out advertising no arguments at
@@ -140,8 +224,9 @@ function resolveRef(node, defs) {
140
224
  * generator emits this shape whenever the argument object is a named type.
141
225
  */
142
226
  function inlineRootRef(parameters) {
143
- if (typeof parameters.$ref !== "string")
227
+ if (typeof parameters.$ref !== 'string') {
144
228
  return parameters;
229
+ }
145
230
  const defs = poolsOf(parameters);
146
231
  const resolved = resolveRef(parameters, defs);
147
232
  // A pointer that lands nowhere has nothing here to resolve against. An object with no
@@ -152,6 +237,12 @@ function inlineRootRef(parameters) {
152
237
  /**
153
238
  * Folds a root `allOf` into the root itself.
154
239
  *
240
+ * @param schema - The root schema. Not mutated.
241
+ * @returns `schema` itself where `allOf` is not a list, or where neither it nor the root has a
242
+ * property. Otherwise a copy holding the root's properties with each branch's laid over them and
243
+ * every `required` name unioned — `allOf` still on it, for `sanitizeSchema` to delete.
244
+ *
245
+ * @remarks
155
246
  * It is the other way a generated schema spells "the arguments are this named type", and
156
247
  * deleting it outright below threw the arguments away while leaving the `required` that named
157
248
  * them. A branch is far more often a reference than an inline object — a named type is exactly
@@ -159,33 +250,40 @@ function inlineRootRef(parameters) {
159
250
  * One that resolves nowhere is not something to guess at, and falls through to `pruneRequired`,
160
251
  * which at least keeps the result self-consistent.
161
252
  */
162
- function mergeRootAllOf(out) {
163
- const branches = out.allOf;
164
- if (!Array.isArray(branches))
165
- return;
166
- const defs = poolsOf(out);
167
- const properties = isObject(out.properties) ? { ...out.properties } : {};
168
- const required = new Set(Array.isArray(out.required) ? out.required.filter((name) => typeof name === "string") : []);
253
+ function mergeRootAllOf(schema) {
254
+ const branches = schema.allOf;
255
+ if (!Array.isArray(branches)) {
256
+ return schema;
257
+ }
258
+ const defs = poolsOf(schema);
259
+ const properties = isRecord(schema.properties) ? { ...schema.properties } : {};
260
+ const required = new Set(requiredOf(schema));
169
261
  for (const raw of branches) {
170
262
  const branch = resolveRef(raw, defs);
171
- if (!branch)
263
+ if (!branch) {
172
264
  continue;
173
- if (isObject(branch.properties))
265
+ }
266
+ if (isRecord(branch.properties)) {
174
267
  Object.assign(properties, branch.properties);
175
- if (Array.isArray(branch.required))
176
- for (const name of branch.required)
177
- if (typeof name === "string")
178
- required.add(name);
268
+ }
269
+ for (const name of requiredOf(branch)) {
270
+ required.add(name);
271
+ }
179
272
  }
180
- if (!Object.keys(properties).length)
181
- return;
182
- out.properties = properties;
183
- if (required.size)
184
- out.required = [...required];
273
+ if (!Object.keys(properties).length) {
274
+ return schema;
275
+ }
276
+ return { ...schema, properties, ...(required.size ? { required: [...required] } : {}) };
185
277
  }
186
278
  /**
187
279
  * Folds a root `anyOf` or `oneOf` into the root itself.
188
280
  *
281
+ * @param schema - The root schema. Not mutated.
282
+ * @returns `schema` itself where no union's branches hold a property. Otherwise a copy holding the
283
+ * root's properties with the branches' laid over them, and the root's `required` plus the names
284
+ * every branch asks for — the union still on it, for `sanitizeSchema` to delete.
285
+ *
286
+ * @remarks
189
287
  * The third spelling of "the arguments are this named type", after `$ref` and `allOf`, and the
190
288
  * one still going out empty: a union of real object shapes has no `null` branch for
191
289
  * `collapseNullableUnion` to take apart, so it reached the delete below intact and every
@@ -197,12 +295,14 @@ function mergeRootAllOf(out) {
197
295
  * this schema's own pools first. One that resolves nowhere still cannot vouch for a name, so its
198
296
  * presence alone empties `required`.
199
297
  */
200
- function mergeRootUnion(out) {
201
- const defs = poolsOf(out);
202
- for (const key of ["anyOf", "oneOf"]) {
298
+ function mergeRootUnion(schema) {
299
+ const defs = poolsOf(schema);
300
+ let out = schema;
301
+ for (const key of ['anyOf', 'oneOf']) {
203
302
  const branches = out[key];
204
- if (!Array.isArray(branches))
303
+ if (!Array.isArray(branches)) {
205
304
  continue;
305
+ }
206
306
  const properties = {};
207
307
  // `null` until a branch has been read, which is what tells "no branches yet" apart from
208
308
  // "the branches agreed on nothing".
@@ -214,27 +314,30 @@ function mergeRootUnion(out) {
214
314
  shared = new Set();
215
315
  continue;
216
316
  }
217
- if (isObject(branch.properties))
317
+ if (isRecord(branch.properties)) {
218
318
  Object.assign(properties, branch.properties);
219
- const names = Array.isArray(branch.required)
220
- ? branch.required.filter((name) => typeof name === "string")
221
- : [];
319
+ }
320
+ const names = requiredOf(branch);
222
321
  shared = previous === null ? new Set(names) : new Set(names.filter((n) => previous.has(n)));
223
322
  }
224
- if (!Object.keys(properties).length)
323
+ if (!Object.keys(properties).length) {
225
324
  continue;
226
- out.properties = { ...(isObject(out.properties) ? out.properties : {}), ...properties };
227
- if (shared?.size) {
228
- const already = Array.isArray(out.required)
229
- ? out.required.filter((name) => typeof name === "string")
230
- : [];
231
- out.required = [...new Set([...already, ...shared])];
232
325
  }
326
+ out = {
327
+ ...out,
328
+ properties: { ...(isRecord(out.properties) ? out.properties : {}), ...properties },
329
+ ...(shared?.size ? { required: [...new Set([...requiredOf(out), ...shared])] } : {}),
330
+ };
233
331
  }
332
+ return out;
234
333
  }
235
334
  /**
236
335
  * Every `$ref` string anywhere under a node, walked as arbitrary JSON rather than as a schema.
237
336
  *
337
+ * @param node - Anything. A value that is neither an array nor an object holds no pointer.
338
+ * @param into - Where the pointers go. Written to, which is how the walk hands them back.
339
+ *
340
+ * @remarks
238
341
  * The keyword-aware walk `strip` does is the wrong way round for this one. Missing a pointer
239
342
  * here means deleting a definition that something still refers to, which breaks the schema;
240
343
  * finding one that was really a string sitting in a `default` or an `enum` costs a definition
@@ -242,22 +345,31 @@ function mergeRootUnion(out) {
242
345
  */
243
346
  function collectRefs(node, into) {
244
347
  if (Array.isArray(node)) {
245
- for (const item of node)
348
+ for (const item of node) {
246
349
  collectRefs(item, into);
350
+ }
247
351
  return;
248
352
  }
249
- if (!isObject(node))
353
+ if (!isRecord(node)) {
250
354
  return;
355
+ }
251
356
  for (const [key, value] of Object.entries(node)) {
252
- if (key === "$ref" && typeof value === "string")
357
+ if (key === '$ref' && typeof value === 'string') {
253
358
  into.add(value);
254
- else
359
+ }
360
+ else {
255
361
  collectRefs(value, into);
362
+ }
256
363
  }
257
364
  }
258
365
  /**
259
366
  * Drops the `definitions` and `$defs` entries that nothing points at any more.
260
367
  *
368
+ * @param schema - The root schema, pools and all. Not mutated.
369
+ * @returns `schema` itself where it has no pool, or every definition is still reached. Otherwise a
370
+ * copy whose pools hold only what is reached, and that lacks a pool nothing reaches into.
371
+ *
372
+ * @remarks
261
373
  * The rewrites above delete whole subtrees — a root combinator once its branches are folded in,
262
374
  * every sibling of a `$ref`, the branch of a union that was only ever `null` — and the pointers
263
375
  * go with them while the pools they named stay behind. On a real Gmail or filesystem schema
@@ -268,80 +380,122 @@ function collectRefs(node, into) {
268
380
  * name another; a cycle among them terminates on the `has` check, whether or not anything
269
381
  * outside it still refers in.
270
382
  */
271
- function pruneDefs(out) {
272
- const pools = ["definitions", "$defs"].filter((key) => isObject(out[key]));
273
- if (!pools.length)
274
- return;
383
+ function pruneDefs(schema) {
384
+ const pools = POOL_KEYS.filter((key) => isRecord(schema[key]));
385
+ if (!pools.length) {
386
+ return schema;
387
+ }
275
388
  const live = {};
276
389
  const visit = (node) => {
277
390
  const pointers = new Set();
278
391
  collectRefs(node, pointers);
279
392
  for (const pointer of pointers) {
280
393
  const target = LOCAL_POINTER.exec(pointer);
281
- if (!target)
394
+ if (!target) {
282
395
  continue;
396
+ }
283
397
  const [, poolKey, name] = target;
284
- const pool = out[poolKey];
285
- if (!isObject(pool) || !(name in pool))
398
+ const pool = schema[poolKey];
399
+ if (!isRecord(pool) || !(name in pool)) {
286
400
  continue;
401
+ }
287
402
  live[poolKey] ??= new Set();
288
403
  const names = live[poolKey];
289
- if (names.has(name))
404
+ if (names.has(name)) {
290
405
  continue;
406
+ }
291
407
  names.add(name);
292
408
  visit(pool[name]);
293
409
  }
294
410
  };
295
411
  // The pools themselves are not roots: a definition is reached from the schema body, or by
296
412
  // another definition that was, or not at all.
297
- const body = { ...out };
298
- for (const key of pools)
413
+ const body = { ...schema };
414
+ for (const key of pools) {
299
415
  delete body[key];
416
+ }
300
417
  visit(body);
418
+ let pruned = schema;
301
419
  for (const key of pools) {
302
420
  const names = live[key];
303
421
  if (!names?.size) {
304
- delete out[key];
422
+ const { [key]: _unreached, ...rest } = pruned;
423
+ pruned = rest;
305
424
  continue;
306
425
  }
307
- const pool = out[key];
308
- if (names.size === Object.keys(pool).length)
426
+ const pool = schema[key];
427
+ if (names.size === Object.keys(pool).length) {
309
428
  continue;
310
- out[key] = Object.fromEntries(Object.entries(pool).filter(([name]) => names.has(name)));
429
+ }
430
+ const reached = Object.entries(pool).filter(([name]) => names.has(name));
431
+ pruned = { ...pruned, [key]: Object.fromEntries(reached) };
311
432
  }
433
+ return pruned;
312
434
  }
313
435
  /**
314
436
  * A required argument that is not in `properties` is one no caller can supply and no strict
315
- * validator will accept. Anything the rewrites above removed, `required` may still name.
437
+ * validator will accept.
438
+ *
439
+ * @param schema - The schema whose `required` is checked against its own `properties`. Not mutated.
440
+ * @returns `schema` itself where `required` is not a list. Otherwise a copy whose `required` names
441
+ * only what `properties` holds, or that has no `required` where no name is left.
442
+ *
443
+ * @remarks
444
+ * Anything the rewrites above removed, `required` may still name.
316
445
  */
317
- function pruneRequired(out) {
318
- if (!Array.isArray(out.required))
319
- return;
320
- const properties = isObject(out.properties) ? out.properties : {};
321
- const kept = out.required.filter((name) => typeof name === "string" && name in properties);
322
- if (kept.length)
323
- out.required = kept;
324
- else
325
- delete out.required;
446
+ function pruneRequired(schema) {
447
+ if (!Array.isArray(schema.required)) {
448
+ return schema;
449
+ }
450
+ const properties = isRecord(schema.properties) ? schema.properties : {};
451
+ const kept = schema.required.filter((name) => typeof name === 'string' && name in properties);
452
+ if (kept.length) {
453
+ return { ...schema, required: kept };
454
+ }
455
+ const { required: _unmet, ...rest } = schema;
456
+ return rest;
326
457
  }
327
- function sanitizeParameters(parameters) {
328
- if (!isObject(parameters))
458
+ /**
459
+ * One JSON Schema as a strict server will accept it, for a schema that is not a tool's parameters.
460
+ *
461
+ * @param schema - The schema as written. Never mutated. Its root is held to an object, as a tool's
462
+ * parameters are, and anything that is not a schema at all comes back as an object with no
463
+ * properties.
464
+ * @returns A new object: `type: "object"` with a `properties` map, no combinator at the root, no
465
+ * `required` name without a property, and no definition that nothing points at.
466
+ *
467
+ * @remarks
468
+ * What `sanitizeTools` does to each tool, without the tool: a `response_format` schema goes through
469
+ * the same grammar converter a tool's parameters do, and a caller holding only the schema had to
470
+ * wrap it in a definition to get here. Nothing is remembered — the cache is keyed on a tool
471
+ * definition's identity, and a bare schema has no object that stands for it across requests.
472
+ */
473
+ export function sanitizeSchema(schema) {
474
+ if (!isRecord(schema)) {
329
475
  return EMPTY_OBJECT();
330
- const out = normalize(inlineRootRef(parameters));
331
- mergeRootAllOf(out);
332
- mergeRootUnion(out);
333
- for (const key of TOP_LEVEL_COMBINATORS)
476
+ }
477
+ // A copy of its own, so what is deleted and set below is this function's and nobody else's.
478
+ const out = { ...mergeRootUnion(mergeRootAllOf(normalize(inlineRootRef(schema)))) };
479
+ for (const key of TOP_LEVEL_COMBINATORS) {
334
480
  delete out[key];
335
- if (out.type !== "object")
336
- out.type = "object";
337
- if (!isObject(out.properties))
481
+ }
482
+ if (out.type !== SchemaType.Object) {
483
+ out.type = SchemaType.Object;
484
+ }
485
+ if (!isRecord(out.properties)) {
338
486
  out.properties = {};
339
- pruneRequired(out);
340
- pruneDefs(out);
341
- return out;
487
+ }
488
+ return pruneDefs(pruneRequired(out));
342
489
  }
343
- /** Rewrites one tool's parameters, leaving a non-function tool alone. */
344
- const mapTool = (tool, fn) => tool.type === "function"
490
+ /**
491
+ * Rewrites one tool's parameters, leaving a non-function tool alone.
492
+ *
493
+ * @param tool - The definition. Not mutated.
494
+ * @param fn - The rewrite. Handed the parameters as they are, a missing one included.
495
+ * @returns A new definition with what `fn` gave back as its parameters, or `tool` itself where it
496
+ * is not a function tool.
497
+ */
498
+ const mapTool = (tool, fn) => tool.type === FUNCTION_TOOL
345
499
  ? {
346
500
  ...tool,
347
501
  function: { ...tool.function, parameters: fn(tool.function.parameters) },
@@ -350,6 +504,7 @@ const mapTool = (tool, fn) => tool.type === "function"
350
504
  /**
351
505
  * Both rewrites are cached against the tool object rather than recomputed.
352
506
  *
507
+ * @remarks
353
508
  * The agent loop rebuilds its tool array on every iteration of every step, and normalising a
354
509
  * couple of dozen MCP schemas is the only walk in a run that is neither a request nor a query.
355
510
  * The pool hands out the same definition objects for the life of a connection, so identity is
@@ -368,88 +523,115 @@ const mapTool = (tool, fn) => tool.type === "function"
368
523
  */
369
524
  const sanitized = new WeakMap();
370
525
  const relaxed = new WeakMap();
371
- /** Looks one up, computing and remembering it on a miss. */
372
- const through = (cache, tools, fn) => tools.map((tool) => {
373
- const hit = cache.get(tool);
374
- if (hit)
375
- return hit;
376
- const built = mapTool(tool, fn);
377
- cache.set(tool, built);
378
- return built;
379
- });
526
+ /**
527
+ * Looks one up, computing and remembering it on a miss.
528
+ *
529
+ * @param cache - The memory of one rewrite, keyed on a definition's identity. Gains an entry for
530
+ * every tool it did not hold.
531
+ * @param tools - The definitions to look up. Not mutated.
532
+ * @param fn - The rewrite the cache remembers, run on a tool's parameters only on a miss.
533
+ * @returns A new array in the order of `tools`, whose entries are the remembered definitions — the
534
+ * same object each time the same tool is asked for.
535
+ */
536
+ const through = (cache, tools, fn) => tools.map((tool) => getOrCreate(cache, tool, () => mapTool(tool, fn)));
380
537
  /**
381
538
  * Tool definitions a strict server will accept, remembered per definition object.
382
539
  *
540
+ * @param tools - The definitions as the pool hands them over. Never mutated — where a schema
541
+ * changed, a new definition is returned in its place.
542
+ * @returns A new array in the same order. Each entry is the definition remembered for its tool, so
543
+ * the same object on every call; a tool that is not a function tool is its own entry.
544
+ *
545
+ * @remarks
383
546
  * The first call on a connection's tools does the work and every later one is a lookup, so
384
547
  * calling this per request costs nothing.
385
- *
386
- * @param tools The definitions as the pool hands them over. Never mutated — where a schema
387
- * changed, a new definition is returned in its place.
388
548
  */
389
- export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeParameters);
549
+ export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeSchema);
390
550
  /**
391
551
  * Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are keyword
392
- * names and perfectly ordinary argument names at once. Matching on the key alone deleted a
393
- * *property* called `format` along with the keyword, leaving the parent's `required` naming an
394
- * argument that no longer existed — which every strict validator rejects, so the retry produced
395
- * the failure it was reaching for. The same distinction keeps the walk out of `default`, `enum`
396
- * and `const`, whose contents are data, not schema.
397
- *
398
- * At module scope rather than inside `relaxTools`, so the closure is made once rather than per
399
- * call — which, on the path this is on, is per request.
552
+ * names and perfectly ordinary argument names at once.
553
+ *
554
+ * @param node - A schema position, or a list of them. Not mutated.
555
+ * @returns A copy without the `pattern` and `format` keywords, here and at every schema position
556
+ * below. A value that is neither an array nor an object comes back as it is.
557
+ *
558
+ * @remarks
559
+ * Matching on the key alone deleted a *property* called `format` along with the keyword, leaving
560
+ * the parent's `required` naming an argument that no longer existed — which every strict validator
561
+ * rejects, so the retry produced the failure it was reaching for. The same distinction keeps the
562
+ * walk out of `default`, `enum` and `const`, whose contents are data, not schema.
400
563
  */
401
564
  const strip = (node) => {
402
- if (Array.isArray(node))
565
+ if (Array.isArray(node)) {
403
566
  return node.map(strip);
404
- if (!isObject(node))
567
+ }
568
+ if (!isRecord(node)) {
405
569
  return node;
570
+ }
406
571
  const out = {};
407
572
  for (const [key, value] of Object.entries(node)) {
408
- if (key === "pattern" || key === "format")
573
+ if (key === 'pattern' || key === 'format') {
409
574
  continue;
410
- if (SCHEMA_KEYS.has(key))
411
- out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
412
- else if (SCHEMA_MAPS.has(key) && isObject(value))
413
- // The keys here are argument names; only the values are schemas.
414
- out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
415
- else
416
- out[key] = value;
575
+ }
576
+ out[key] = mapChildren(key, value, strip);
417
577
  }
418
578
  return out;
419
579
  };
420
580
  /**
421
- * The retry shape: llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in
422
- * `pattern` and most `format` values, both of which only ever narrowed a string the tool
423
- * re-validates anyway.
581
+ * One schema without its `pattern` and `format` keywords, for a schema that is not a tool's.
424
582
  *
425
- * @param tools Already sanitised. Relaxing is the retry, not a substitute for `sanitizeTools`.
583
+ * @param schema - Already sanitised. Relaxing is the retry, not a substitute for `sanitizeSchema`.
584
+ * Never mutated; anything that is not a schema comes back as an object with no properties.
585
+ * @returns A new object, the keywords gone from every schema position in it.
586
+ *
587
+ * @remarks
588
+ * The retry `relaxTools` makes, on a bare schema. Only the keywords go: a property that happens to
589
+ * be called `format` is an argument name and stays.
426
590
  */
427
- export const relaxTools = (tools) => through(relaxed, tools, (parameters) => {
428
- const stripped = strip(parameters);
429
- return isObject(stripped) ? stripped : EMPTY_OBJECT();
430
- });
591
+ export function relaxSchema(schema) {
592
+ const stripped = strip(schema);
593
+ return isRecord(stripped) ? stripped : EMPTY_OBJECT();
594
+ }
595
+ /**
596
+ * The tool array with every schema's `pattern` and `format` keywords gone — the shape a turn is
597
+ * retried with.
598
+ *
599
+ * @param tools - Already sanitised. Relaxing is the retry, not a substitute for `sanitizeTools`.
600
+ * Never mutated.
601
+ * @returns A new array in the same order. Each entry is the relaxed definition remembered for its
602
+ * tool, so the same object on every call; a tool that is not a function tool is its own entry.
603
+ *
604
+ * @remarks
605
+ * llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in `pattern` and most
606
+ * `format` values, both of which only ever narrowed a string the tool re-validates anyway.
607
+ */
608
+ export const relaxTools = (tools) => through(relaxed, tools, relaxSchema);
431
609
  /**
432
610
  * Qwen chat templates raise this when the transcript has no user turn. Some servers wrap it
433
611
  * in the same "unable to generate parser" wording as a real schema failure, and stripping
434
612
  * keywords would not fix it.
435
613
  */
436
- const NO_USER_QUERY = "no user query found";
614
+ const NO_USER_QUERY = 'no user query found';
437
615
  /**
438
- * Does this failure look like the server could not build a grammar from our tool schemas?
616
+ * Whether a failure looks like the server could not build a grammar from our tool schemas.
439
617
  *
618
+ * @param message - The server's error text. Matched case-insensitively.
619
+ * @returns `true` for a grammar or schema-conversion failure. `false` for anything else, and
620
+ * always for a chat template's "no user query found", whatever it is wrapped in.
621
+ *
622
+ * @remarks
440
623
  * Every server words this differently — llama-server says "error parsing grammar", Lemonade
441
624
  * says "Failed to initialize samplers: failed to parse grammar", others surface the converter
442
625
  * by name. Since a grammar is only ever involved in constrained decoding, treat any mention of
443
626
  * one as ours; the retry is cheap and latches after a single request.
444
- *
445
- * @param message The server's error text. Matched case-insensitively.
446
627
  */
447
628
  export function isGrammarError(message) {
448
629
  const text = message.toLowerCase();
449
- if (text.includes(NO_USER_QUERY))
630
+ if (text.includes(NO_USER_QUERY)) {
450
631
  return false;
451
- return (text.includes("grammar") ||
452
- text.includes("unrecognized schema") ||
453
- text.includes("json schema conversion failed") ||
454
- (text.includes("unable to generate parser") && text.includes("template")));
632
+ }
633
+ return (text.includes('grammar') ||
634
+ text.includes('unrecognized schema') ||
635
+ text.includes('json schema conversion failed') ||
636
+ (text.includes('unable to generate parser') && text.includes('template')));
455
637
  }