@cubicecho/agent-core 2.16.0 → 2.18.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.
@@ -229,3 +229,76 @@ export declare const preselectInput: (catalog: CatalogServer[], prompt: string,
229
229
  * `preselectSystem` was given, or the model is being held to a cap it was never told about.
230
230
  */
231
231
  export declare function preselection(names: unknown, catalog: CatalogServer[], maxPerLoad?: number): string[];
232
+ /**
233
+ * The least a best match may score and still be acted on without a model.
234
+ *
235
+ * A BM25 score, so it is read against the shape of the corpus rather than as a percentage: a
236
+ * query term carried by half the catalogue is worth about 0.7, and one carried by a single tool
237
+ * about 3. One at this floor is therefore "something more distinctive than a word every other
238
+ * tool uses", which is the weakest evidence worth skipping a round trip on.
239
+ *
240
+ * A term is distinctive only against other terms, so a catalogue of three or four tools rarely
241
+ * clears it. That is the right answer rather than a gap: a catalogue that small is not costing
242
+ * enough tokens to be worth choosing from in the first place.
243
+ */
244
+ export declare const KEYWORD_MIN_SCORE = 1;
245
+ /**
246
+ * How far the best unpicked tool must fall below the last picked one for the cut to count clean.
247
+ *
248
+ * Half. The cap is the only reason a hit is dropped, so a hit just underneath it scoring nearly
249
+ * as much as one just above means the ranking chose arbitrarily, which is exactly the case a
250
+ * model should be spent on.
251
+ */
252
+ export declare const KEYWORD_DROPOFF = 0.5;
253
+ /** One tool's score against a request. */
254
+ export interface ToolMatch {
255
+ name: string;
256
+ /** Its BM25 score. Zero-scoring tools are not ranked at all. */
257
+ score: number;
258
+ }
259
+ /** What `preselectByKeywords` found. */
260
+ export interface KeywordPreselection {
261
+ /** The names, best first, capped at `maxPerLoad`. */
262
+ names: string[];
263
+ /** Whether the match is clear enough to run on without asking a model. */
264
+ confident: boolean;
265
+ /** Every tool that scored at all, best first — for a caller measuring its own threshold. */
266
+ ranked: ToolMatch[];
267
+ }
268
+ /** What `preselectByKeywords` takes besides the catalogue and the request. */
269
+ export interface KeywordPreselectOptions {
270
+ /** The most to pick, defaulting to `MAX_PER_LOAD`. The same cap the model is held to. */
271
+ maxPerLoad?: number;
272
+ /** The floor under a confident best match, defaulting to `KEYWORD_MIN_SCORE`. */
273
+ minScore?: number;
274
+ /** The gap a confident cut needs, defaulting to `KEYWORD_DROPOFF`. */
275
+ dropoff?: number;
276
+ /** Where the request is cut, defaulting to 2000 — the same head `preselectInput` reads. */
277
+ maxPromptChars?: number;
278
+ }
279
+ /**
280
+ * The tools a request's own words point at, ranked, and whether they point clearly enough.
281
+ *
282
+ * A preselection call costs a round trip to a model that is being asked to do term matching, and
283
+ * on a local box that is seconds before the run has started. For a catalogue of a few dozen tools
284
+ * the words usually decide it: a request that says "commit" and a tool called `git__commit` need
285
+ * no reasoning to connect.
286
+ *
287
+ * BM25 rather than counting shared words, because the ranking has to survive the words every tool
288
+ * uses. "list", "get" and "file" are in half the descriptions in a real catalogue, and a plain
289
+ * overlap count hands the top of the ranking to whichever tool has the longest description. The
290
+ * inverse document frequency makes a term worth what it distinguishes, and the length
291
+ * normalisation stops a wordy description from outscoring the tool actually named.
292
+ *
293
+ * `confident` is what a caller acts on, and it is deliberately hard to earn: something more
294
+ * distinctive than a word the whole catalogue shares has to have matched, and the tools left
295
+ * unpicked have to score well below the ones picked. Anything else is ambiguous, and ambiguous is
296
+ * what the model is for. Nothing matching is not confident either — the words cannot tell "this
297
+ * request needs no tools" from "these words are not in the catalogue".
298
+ *
299
+ * @param catalog The servers to choose from. Each tool is matched on its name, its server's label
300
+ * and its one-line description, which is everything the catalogue holds.
301
+ * @param prompt The request being planned for. Only its head is read, as in `preselectInput`.
302
+ * @param options The cap, the two confidence thresholds, and where the request is cut.
303
+ */
304
+ export declare function preselectByKeywords(catalog: CatalogServer[], prompt: string, { maxPerLoad, minScore, dropoff, maxPromptChars, }?: KeywordPreselectOptions): KeywordPreselection;
@@ -394,3 +394,128 @@ export function preselection(names, catalog, maxPerLoad = MAX_PER_LOAD) {
394
394
  const wanted = list.filter((name) => typeof name === "string");
395
395
  return expandNames(wanted, catalog, maxPerLoad).matched.slice(0, maxPerLoad);
396
396
  }
397
+ /**
398
+ * Saturation and length normalisation for the BM25 score. Robertson's usual values.
399
+ *
400
+ * Nothing here is tuned for this corpus, because tuning them against a catalogue of forty short
401
+ * documents would be fitting noise. `dropoff` and `minScore` are the knobs worth turning.
402
+ */
403
+ const BM25_K1 = 1.2;
404
+ const BM25_B = 0.75;
405
+ /**
406
+ * The least a best match may score and still be acted on without a model.
407
+ *
408
+ * A BM25 score, so it is read against the shape of the corpus rather than as a percentage: a
409
+ * query term carried by half the catalogue is worth about 0.7, and one carried by a single tool
410
+ * about 3. One at this floor is therefore "something more distinctive than a word every other
411
+ * tool uses", which is the weakest evidence worth skipping a round trip on.
412
+ *
413
+ * A term is distinctive only against other terms, so a catalogue of three or four tools rarely
414
+ * clears it. That is the right answer rather than a gap: a catalogue that small is not costing
415
+ * enough tokens to be worth choosing from in the first place.
416
+ */
417
+ export const KEYWORD_MIN_SCORE = 1;
418
+ /**
419
+ * How far the best unpicked tool must fall below the last picked one for the cut to count clean.
420
+ *
421
+ * Half. The cap is the only reason a hit is dropped, so a hit just underneath it scoring nearly
422
+ * as much as one just above means the ranking chose arbitrarily, which is exactly the case a
423
+ * model should be spent on.
424
+ */
425
+ export const KEYWORD_DROPOFF = 0.5;
426
+ /**
427
+ * English function words, dropped before matching.
428
+ *
429
+ * The inverse document frequency is supposed to make this unnecessary, and over a real corpus it
430
+ * would: a word carried by every document is worth nothing. But a tool catalogue is twenty
431
+ * one-line descriptions, and at that size "for" or "on" is rare by accident — it lands in one
432
+ * description, scores as the most distinctive term in the query, and a request that says "for me"
433
+ * is answered with whichever tool happened to use the word. Only closure-class words are here;
434
+ * "list", "get", "run" and "show" are what tools are called and stay.
435
+ */
436
+ const NOISE = new Set(("about all also am an and any are as at be been being but by can could do does for from had " +
437
+ "has have how if in into is it its just me more most my no not of on or other our out over " +
438
+ "please should so some such than that the their them then there these they this to too up us " +
439
+ "very was we were what when where which who will with would you your").split(" "));
440
+ /**
441
+ * A text as the matcher reads it: lowercase words, `server__tool_name` and camelCase split apart.
442
+ *
443
+ * Plurals are folded, crudely, by dropping a trailing `s`: a request says "read the files" and
444
+ * the tool is called `read_file`, and without this the two do not meet. Nothing else is stemmed —
445
+ * a real stemmer is a table of English morphology, and this is matching identifiers.
446
+ */
447
+ const terms = (text) => text
448
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
449
+ .toLowerCase()
450
+ .split(/[^a-z0-9]+/)
451
+ .filter((word) => word.length > 1 && !NOISE.has(word))
452
+ .map((word) => word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word);
453
+ /**
454
+ * The tools a request's own words point at, ranked, and whether they point clearly enough.
455
+ *
456
+ * A preselection call costs a round trip to a model that is being asked to do term matching, and
457
+ * on a local box that is seconds before the run has started. For a catalogue of a few dozen tools
458
+ * the words usually decide it: a request that says "commit" and a tool called `git__commit` need
459
+ * no reasoning to connect.
460
+ *
461
+ * BM25 rather than counting shared words, because the ranking has to survive the words every tool
462
+ * uses. "list", "get" and "file" are in half the descriptions in a real catalogue, and a plain
463
+ * overlap count hands the top of the ranking to whichever tool has the longest description. The
464
+ * inverse document frequency makes a term worth what it distinguishes, and the length
465
+ * normalisation stops a wordy description from outscoring the tool actually named.
466
+ *
467
+ * `confident` is what a caller acts on, and it is deliberately hard to earn: something more
468
+ * distinctive than a word the whole catalogue shares has to have matched, and the tools left
469
+ * unpicked have to score well below the ones picked. Anything else is ambiguous, and ambiguous is
470
+ * what the model is for. Nothing matching is not confident either — the words cannot tell "this
471
+ * request needs no tools" from "these words are not in the catalogue".
472
+ *
473
+ * @param catalog The servers to choose from. Each tool is matched on its name, its server's label
474
+ * and its one-line description, which is everything the catalogue holds.
475
+ * @param prompt The request being planned for. Only its head is read, as in `preselectInput`.
476
+ * @param options The cap, the two confidence thresholds, and where the request is cut.
477
+ */
478
+ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD, minScore = KEYWORD_MIN_SCORE, dropoff = KEYWORD_DROPOFF, maxPromptChars = PRESELECT_PROMPT_CHARS, } = {}) {
479
+ const empty = { names: [], confident: false, ranked: [] };
480
+ const docs = catalog.flatMap((server) => server.tools.map((tool) => ({
481
+ name: tool.name,
482
+ terms: terms(`${tool.name} ${server.label} ${tool.description}`),
483
+ })));
484
+ // A query term repeated in the request is not worth more than one said once: the request is
485
+ // prose about a task, not a document being matched against another document.
486
+ const query = new Set(terms(prompt.slice(0, maxPromptChars)));
487
+ if (!docs.length || !query.size)
488
+ return empty;
489
+ const length = docs.reduce((total, doc) => total + doc.terms.length, 0) / docs.length;
490
+ const documents = new Map();
491
+ for (const doc of docs)
492
+ for (const term of new Set(doc.terms))
493
+ documents.set(term, (documents.get(term) ?? 0) + 1);
494
+ const ranked = docs
495
+ .map((doc) => {
496
+ const counts = new Map();
497
+ for (const term of doc.terms)
498
+ counts.set(term, (counts.get(term) ?? 0) + 1);
499
+ let score = 0;
500
+ for (const term of query) {
501
+ const found = counts.get(term);
502
+ if (!found)
503
+ continue;
504
+ const held = documents.get(term) ?? 0;
505
+ const idf = Math.log(1 + (docs.length - held + 0.5) / (held + 0.5));
506
+ const norm = BM25_K1 * (1 - BM25_B + (BM25_B * doc.terms.length) / length);
507
+ score += (idf * found * (BM25_K1 + 1)) / (found + norm);
508
+ }
509
+ return { name: doc.name, score };
510
+ })
511
+ .filter((hit) => hit.score > 0)
512
+ // Ties break on the name, not on where the tool sat in the catalogue, so reconnecting a
513
+ // server in a different order does not change what a run opens with.
514
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
515
+ if (!ranked.length)
516
+ return empty;
517
+ const names = ranked.slice(0, maxPerLoad).map((hit) => hit.name);
518
+ const cut = ranked[names.length - 1].score;
519
+ const next = ranked[maxPerLoad]?.score ?? 0;
520
+ return { names, confident: ranked[0].score >= minScore && next <= dropoff * cut, ranked };
521
+ }
package/llms.txt CHANGED
@@ -234,6 +234,34 @@ What endpoints and models refused, carried across a restart.
234
234
  - `importCapabilities` — Latches what a stored snapshot says was refused, on top of whatever this process has learned.
235
235
  - `ModelSnapshot` (type) — What one model on an endpoint refused.
236
236
 
237
+ ### spec
238
+
239
+ A JSON document that defines an agent, and the rules for reading one.
240
+
241
+ - `AGENT_SPEC` — The token a document declares itself with.
242
+ - `AGENT_SPEC_VERSION` — The major version `AGENT_SPEC` pins.
243
+ - `AGENT_TASKS` — The side tasks this package has code for: documentation and a sensible UI order, not a validator.
244
+ - `AgentHook` (type) — A tool call bound to a lifecycle event, run by the host rather than the model.
245
+ - `AgentSpec` (type) — One agent, as a document.
246
+ - `EndpointSpec` (type) — Where a model is reached, and how long to wait for it.
247
+ - `ExportSpecOptions` (type) — What `exportSpec` takes besides the document.
248
+ - `exportSpec` — A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
249
+ - `ModelSpec` (type) — Which model answers and how, including the request fields this interface cannot spell.
250
+ - `ParsedSpec` (type) — What `parseSpec` hands back.
251
+ - `ParseSpecOptions` (type) — What `parseSpec` takes besides the document.
252
+ - `PromptPart` (type) — One layer of system prompt, named so a layer above can stack beside it or replace it.
253
+ - `parseSpec` — Reads a document, dropping what it cannot use and refusing only what it cannot identify.
254
+ - `RESOLVED_DEFAULTS` — What a field falls back to when no layer said anything at all.
255
+ - `ResolvedAgent` (type) — What layered documents resolve to: the flat shape the loop already takes, plus the parts of an agent that are the host's to act on.
256
+ - `ResolvedTask` (type) — One side task, resolved: a model, and the endpoint it is reached through.
257
+ - `RetrySpec` (type) — What to do about a request lost before the model produced anything.
258
+ - `resolveAgentSpec` — Layers documents into the flat object the loop takes, weakest first.
259
+ - `SPEC_EVENTS` — Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
260
+ - `SpecBundle` (type) — What an agent carries so it can travel whole: the definitions of the servers it names.
261
+ - `SpecServer` (type) — A bundled MCP server, validated only as far as a round trip needs and otherwise passed through.
262
+ - `TaskSpec` (type) — One side task, which may run on a different model — and a different machine — than the agent.
263
+ - `ToolsSpec` (type) — How tools reach the model, and which servers this agent may reach at all.
264
+
237
265
  ### stream
238
266
 
239
267
  Reading one streamed turn back into a message.
@@ -277,6 +305,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
277
305
  - `catalogPrompt` — The catalogue block appended to the system prompt.
278
306
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
279
307
  - `inCatalog` — Whether the catalogue holds a tool by this name.
308
+ - `KEYWORD_DROPOFF` — How far the best unpicked tool must fall below the last picked one for the cut to count clean.
309
+ - `KEYWORD_MIN_SCORE` — The least a best match may score and still be acted on without a model.
310
+ - `KeywordPreselection` (type) — What `preselectByKeywords` found.
311
+ - `KeywordPreselectOptions` (type) — What `preselectByKeywords` takes besides the catalogue and the request.
280
312
  - `LOAD_TOOLS` — On-demand tool loading.
281
313
  - `LOAD_TOOLS_DEFINITION` — One object for the life of the process — the agent loop asks for it on every iteration.
282
314
  - `loadedTools` — A tool array with newly loaded definitions appended, in the order they were loaded.
@@ -286,8 +318,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
286
318
  - `orderTools` — The tool array in a stable order, so the same set of tools renders the same way twice.
287
319
  - `PRESELECT_SCHEMA` — The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
288
320
  - `PRESELECT_SYSTEM` — The preselection system prompt at the default cap, for a caller that never changes it.
321
+ - `preselectByKeywords` — The tools a request's own words point at, ranked, and whether they point clearly enough.
289
322
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
290
323
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
291
324
  - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
292
325
  - `requestedNames` — `load_tools` arguments, defensively — a model may send a bare string or a nested object.
326
+ - `ToolMatch` (type) — One tool's score against a request.
293
327
  - `ToolOrder` (type) — How a tool array is ordered before it is sent: `true` by name, `false` as the caller built it, or a comparator over the two names.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
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",
@@ -31,6 +31,10 @@
31
31
  ".": {
32
32
  "types": "./dist/index.d.ts",
33
33
  "import": "./dist/index.js"
34
+ },
35
+ "./spec": {
36
+ "types": "./dist/spec.d.ts",
37
+ "import": "./dist/spec.js"
34
38
  }
35
39
  },
36
40
  "files": [