@cubicecho/agent-core 2.0.6 → 2.0.8

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/dist/retry.js CHANGED
@@ -61,10 +61,16 @@ function messageChars(message) {
61
61
  /**
62
62
  * The tools half, cached against the array.
63
63
  *
64
- * Tool definitions are stable objects handed out by a pool, and `sanitizeTools` already caches on
65
- * that same identity — so the array a turn sends is the array the last turn sent unless something
66
- * reconnected. Serialising two dozen JSON schemas to measure them, on every turn, to get the same
67
- * number every time, was the more expensive half of this function.
64
+ * Serialising two dozen JSON schemas to measure them, on every turn, to get the same number every
65
+ * time, was the more expensive half of this function.
66
+ *
67
+ * The array is the key, so this pays only a caller that hands the same one back. That is not what
68
+ * a turn built through `sanitizeTools` or `relaxTools` does — both are a `map`, so each build
69
+ * allocates a fresh array however stable the tools inside it are, and those builds miss here every
70
+ * time. It is not free to change: keying on the tools instead would hit for them, at the cost of
71
+ * the array-level memoisation `tests/retry.test.ts` pins, which deliberately holds a mutated array
72
+ * to its first reading. Sizing happens once per turn either way, so the miss costs one walk of the
73
+ * schemas rather than a walk per attempt.
68
74
  */
69
75
  const toolTokens = new WeakMap();
70
76
  function toolsCost(tools) {
@@ -99,55 +99,76 @@ function collapseNullableUnion(node) {
99
99
  const TOP_LEVEL_COMBINATORS = ["allOf", "anyOf", "oneOf", "enum", "not"];
100
100
  /** `#/definitions/Args` or `#/$defs/Args` — a pointer into this schema's own definitions. */
101
101
  const LOCAL_POINTER = /^#\/(definitions|\$defs)\/([^/]+)$/;
102
- /**
103
- * Replaces a root-level `$ref` with what it points at.
104
- *
105
- * Dropping the siblings of a `$ref` is right at a nested position and wrong at this one: the
106
- * siblings here are the `definitions` the pointer needs, so the reference is left dangling and
107
- * `properties` is then backfilled empty below. The tool goes out advertising no arguments at
108
- * all — which the model cannot detect and the server has no reason to refuse. A schema
109
- * generator emits this shape whenever the argument object is a named type.
110
- */
111
- function inlineRootRef(parameters) {
102
+ /** The `definitions` and `$defs` that a local pointer in this schema resolves against. */
103
+ function poolsOf(parameters) {
112
104
  const defs = {};
113
105
  for (const key of ["definitions", "$defs"])
114
106
  if (isObject(parameters[key]))
115
107
  defs[key] = parameters[key];
108
+ return defs;
109
+ }
110
+ /**
111
+ * Follows a chain of local references to the schema it arrives at, or `undefined` where it
112
+ * 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.
115
+ *
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.
118
+ */
119
+ function resolveRef(node, defs) {
116
120
  const seen = new Set();
117
- let node = parameters;
118
- while (typeof node.$ref === "string") {
119
- const pointer = node.$ref;
121
+ let current = node;
122
+ while (isObject(current) && typeof current.$ref === "string") {
123
+ const pointer = current.$ref;
120
124
  const target = LOCAL_POINTER.exec(pointer);
121
- // A pointer at another document, or one that comes back to itself, has nothing here to
122
- // resolve against. An object with no properties is at least honest about taking none.
123
125
  if (!target || seen.has(pointer))
124
- return EMPTY_OBJECT();
126
+ return undefined;
125
127
  seen.add(pointer);
126
128
  const pool = defs[target[1]];
127
- const resolved = isObject(pool) ? pool[target[2]] : undefined;
128
- if (!isObject(resolved))
129
- return EMPTY_OBJECT();
130
- node = resolved;
129
+ current = isObject(pool) ? pool[target[2]] : undefined;
131
130
  }
132
- // The definitions travel with it: whatever the target refers to still lives in them.
133
- return node === parameters ? parameters : { ...node, ...defs };
131
+ return isObject(current) ? current : undefined;
132
+ }
133
+ /**
134
+ * Replaces a root-level `$ref` with what it points at.
135
+ *
136
+ * Dropping the siblings of a `$ref` is right at a nested position and wrong at this one: the
137
+ * siblings here are the `definitions` the pointer needs, so the reference is left dangling and
138
+ * `properties` is then backfilled empty below. The tool goes out advertising no arguments at
139
+ * all — which the model cannot detect and the server has no reason to refuse. A schema
140
+ * generator emits this shape whenever the argument object is a named type.
141
+ */
142
+ function inlineRootRef(parameters) {
143
+ if (typeof parameters.$ref !== "string")
144
+ return parameters;
145
+ const defs = poolsOf(parameters);
146
+ const resolved = resolveRef(parameters, defs);
147
+ // A pointer that lands nowhere has nothing here to resolve against. An object with no
148
+ // properties is at least honest about taking none.
149
+ // The definitions travel with what it did land on: whatever that refers to still lives in them.
150
+ return resolved ? { ...resolved, ...defs } : EMPTY_OBJECT();
134
151
  }
135
152
  /**
136
153
  * Folds a root `allOf` into the root itself.
137
154
  *
138
155
  * It is the other way a generated schema spells "the arguments are this named type", and
139
156
  * deleting it outright below threw the arguments away while leaving the `required` that named
140
- * them. Branches that are references are not something to guess atthose fall through to
141
- * `pruneRequired`, which at least keeps the result self-consistent.
157
+ * them. A branch is far more often a reference than an inline object a named type is exactly
158
+ * what a generator puts in `$defs` so each is resolved against this schema's own pools first.
159
+ * One that resolves nowhere is not something to guess at, and falls through to `pruneRequired`,
160
+ * which at least keeps the result self-consistent.
142
161
  */
143
162
  function mergeRootAllOf(out) {
144
163
  const branches = out.allOf;
145
164
  if (!Array.isArray(branches))
146
165
  return;
166
+ const defs = poolsOf(out);
147
167
  const properties = isObject(out.properties) ? { ...out.properties } : {};
148
168
  const required = new Set(Array.isArray(out.required) ? out.required.filter((name) => typeof name === "string") : []);
149
- for (const branch of branches) {
150
- if (!isObject(branch) || "$ref" in branch)
169
+ for (const raw of branches) {
170
+ const branch = resolveRef(raw, defs);
171
+ if (!branch)
151
172
  continue;
152
173
  if (isObject(branch.properties))
153
174
  Object.assign(properties, branch.properties);
@@ -170,10 +191,14 @@ function mergeRootAllOf(out) {
170
191
  * `collapseNullableUnion` to take apart, so it reached the delete below intact and every
171
192
  * argument went with it. Properties are unioned because a caller satisfies any one branch;
172
193
  * `required` keeps only the names every branch asks for, since one that a branch does without
173
- * is one the model has to be free to omit. A branch that is a reference is not something to
174
- * guess at it cannot vouch for a name, so its presence alone empties `required`.
194
+ * is one the model has to be free to omit. The branches of a discriminated union arrive as
195
+ * references rather than inline Pydantic, zod-to-json-schema and the MCP TypeScript SDK all
196
+ * emit the shapes into `$defs` and point at them from the root — so each is resolved against
197
+ * this schema's own pools first. One that resolves nowhere still cannot vouch for a name, so its
198
+ * presence alone empties `required`.
175
199
  */
176
200
  function mergeRootUnion(out) {
201
+ const defs = poolsOf(out);
177
202
  for (const key of ["anyOf", "oneOf"]) {
178
203
  const branches = out[key];
179
204
  if (!Array.isArray(branches))
@@ -182,9 +207,10 @@ function mergeRootUnion(out) {
182
207
  // `null` until a branch has been read, which is what tells "no branches yet" apart from
183
208
  // "the branches agreed on nothing".
184
209
  let shared = null;
185
- for (const branch of branches) {
210
+ for (const raw of branches) {
186
211
  const previous = shared;
187
- if (!isObject(branch) || "$ref" in branch) {
212
+ const branch = resolveRef(raw, defs);
213
+ if (!branch) {
188
214
  shared = new Set();
189
215
  continue;
190
216
  }
@@ -206,6 +232,84 @@ function mergeRootUnion(out) {
206
232
  }
207
233
  }
208
234
  }
235
+ /**
236
+ * Every `$ref` string anywhere under a node, walked as arbitrary JSON rather than as a schema.
237
+ *
238
+ * The keyword-aware walk `strip` does is the wrong way round for this one. Missing a pointer
239
+ * here means deleting a definition that something still refers to, which breaks the schema;
240
+ * finding one that was really a string sitting in a `default` or an `enum` costs a definition
241
+ * that outlives its last real reference. So this errs the cheap way and reads every position.
242
+ */
243
+ function collectRefs(node, into) {
244
+ if (Array.isArray(node)) {
245
+ for (const item of node)
246
+ collectRefs(item, into);
247
+ return;
248
+ }
249
+ if (!isObject(node))
250
+ return;
251
+ for (const [key, value] of Object.entries(node)) {
252
+ if (key === "$ref" && typeof value === "string")
253
+ into.add(value);
254
+ else
255
+ collectRefs(value, into);
256
+ }
257
+ }
258
+ /**
259
+ * Drops the `definitions` and `$defs` entries that nothing points at any more.
260
+ *
261
+ * The rewrites above delete whole subtrees — a root combinator once its branches are folded in,
262
+ * every sibling of a `$ref`, the branch of a union that was only ever `null` — and the pointers
263
+ * go with them while the pools they named stay behind. On a real Gmail or filesystem schema
264
+ * those pools are most of the parameter bytes, re-sent for every tool on every turn of every
265
+ * run, describing shapes the request no longer mentions anywhere.
266
+ *
267
+ * Reachability rather than a single pass, because a definition that is still pointed at can
268
+ * name another; a cycle among them terminates on the `has` check, whether or not anything
269
+ * outside it still refers in.
270
+ */
271
+ function pruneDefs(out) {
272
+ const pools = ["definitions", "$defs"].filter((key) => isObject(out[key]));
273
+ if (!pools.length)
274
+ return;
275
+ const live = {};
276
+ const visit = (node) => {
277
+ const pointers = new Set();
278
+ collectRefs(node, pointers);
279
+ for (const pointer of pointers) {
280
+ const target = LOCAL_POINTER.exec(pointer);
281
+ if (!target)
282
+ continue;
283
+ const [, poolKey, name] = target;
284
+ const pool = out[poolKey];
285
+ if (!isObject(pool) || !(name in pool))
286
+ continue;
287
+ live[poolKey] ??= new Set();
288
+ const names = live[poolKey];
289
+ if (names.has(name))
290
+ continue;
291
+ names.add(name);
292
+ visit(pool[name]);
293
+ }
294
+ };
295
+ // The pools themselves are not roots: a definition is reached from the schema body, or by
296
+ // another definition that was, or not at all.
297
+ const body = { ...out };
298
+ for (const key of pools)
299
+ delete body[key];
300
+ visit(body);
301
+ for (const key of pools) {
302
+ const names = live[key];
303
+ if (!names?.size) {
304
+ delete out[key];
305
+ continue;
306
+ }
307
+ const pool = out[key];
308
+ if (names.size === Object.keys(pool).length)
309
+ continue;
310
+ out[key] = Object.fromEntries(Object.entries(pool).filter(([name]) => names.has(name)));
311
+ }
312
+ }
209
313
  /**
210
314
  * A required argument that is not in `properties` is one no caller can supply and no strict
211
315
  * validator will accept. Anything the rewrites above removed, `required` may still name.
@@ -233,6 +337,7 @@ function sanitizeParameters(parameters) {
233
337
  if (!isObject(out.properties))
234
338
  out.properties = {};
235
339
  pruneRequired(out);
340
+ pruneDefs(out);
236
341
  return out;
237
342
  }
238
343
  /** Rewrites one tool's parameters, leaving a non-function tool alone. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",