@cubicecho/agent-core 2.16.0 → 2.17.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 CHANGED
@@ -21,7 +21,7 @@ only, Node >=22.
21
21
  | Module | What it does |
22
22
  | --- | --- |
23
23
  | `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
24
- | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. |
24
+ | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. Plus `preselectByKeywords`, which picks from it without a model. |
25
25
  | `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
26
26
  | `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
27
27
  | `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
@@ -441,6 +441,33 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
441
441
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
442
442
  and everything is back from the second step on.
443
443
 
444
+ That preselection costs a round trip to a model, which on a local box is a few seconds before the
445
+ run has started, spent on a model doing term matching. `preselect(..., { keywords: true })` does
446
+ the matching directly and spends the model only on what the words cannot settle:
447
+
448
+ ```ts
449
+ const preselected = await preselect(config, config.toolSelectModel, catalog, prompt, {
450
+ keywords: true, // or { minScore, dropoff } to move the bar
451
+ onNotice,
452
+ });
453
+ ```
454
+
455
+ `preselectByKeywords` is the matcher on its own, ranking every tool against the request by BM25
456
+ over its name, its server's label and its one-line description. BM25 rather than counting shared
457
+ words, because a catalogue is full of words every tool uses — "list", "get", "file" — and an
458
+ overlap count hands the top of the ranking to whichever tool has the longest description. English
459
+ function words are dropped outright: the inverse document frequency is meant to handle them, and
460
+ over a real corpus it would, but twenty one-line descriptions are few enough that "for" lands in
461
+ one of them and scores as the most distinctive word in the request.
462
+
463
+ What a caller acts on is `confident`, which is deliberately hard to earn: something more
464
+ distinctive than a word the catalogue shares has to have matched, and the tools the cap left out
465
+ have to score well below the ones it kept — a hit just under the line scoring nearly as much as
466
+ one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
467
+ on. Matching nothing is not confident either, since the words cannot tell a request that needs no
468
+ tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
469
+ preselection at all, words included.
470
+
444
471
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
445
472
  continued first. `continueTurn` is the same thing for a caller with its own loop:
446
473
 
@@ -5,7 +5,7 @@ import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts
5
5
  import { type RunEventInput, type RunMetrics } from "./events.ts";
6
6
  import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
7
7
  import type { Turn, TurnUsage } from "./stream.ts";
8
- import { type ToolOrder } from "./tool-loading.ts";
8
+ import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
9
9
  /**
10
10
  * The one place a streamed request's body is decided from a config and what the endpoint and
11
11
  * the model have refused.
@@ -72,19 +72,30 @@ export declare function resolveApiKey(own: {
72
72
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
73
73
  * since a side task is never worth failing the run. A stop still throws.
74
74
  *
75
+ * With `keywords`, the request's own words are matched against the catalogue first and the model
76
+ * is spent only on what they cannot settle, which on a local box is the difference between a run
77
+ * starting now and starting in a few seconds. The words have to be clear about it; see
78
+ * `preselectByKeywords` for what that means.
79
+ *
75
80
  * @param config The endpoint the preselector is reached through.
76
81
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
77
82
  * means by empty.
78
83
  * @param catalog The servers to choose from.
79
84
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
80
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
81
- * (`MAX_PER_LOAD`).
85
+ * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
86
+ * (`MAX_PER_LOAD`), and whether to try the words first.
82
87
  */
83
- export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, }?: {
88
+ export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, keywords, }?: {
84
89
  signal?: AbortSignal;
85
90
  onNotice?: (message: string) => void;
86
91
  maxTokens?: number;
87
92
  maxPerLoad?: number;
93
+ /**
94
+ * Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
95
+ * takes its defaults; an object tunes the thresholds. An empty `model` still means no
96
+ * preselection at all, words included — that is what `toolSelectModel: ""` asks for.
97
+ */
98
+ keywords?: boolean | KeywordPreselectOptions;
88
99
  }): Promise<string[]>;
89
100
  /** One call the model made, as `dispatch` is handed it. */
90
101
  export interface ToolCallRequest {
@@ -10,7 +10,7 @@ import { runTurn } from "./run-turn.js";
10
10
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
11
11
  import { askJson, tryAsk } from "./side-task.js";
12
12
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
13
- import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
13
+ import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
14
14
  /**
15
15
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
16
16
  * asking.
@@ -114,17 +114,32 @@ export function resolveApiKey(own, inherited, env = process.env) {
114
114
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
115
115
  * since a side task is never worth failing the run. A stop still throws.
116
116
  *
117
+ * With `keywords`, the request's own words are matched against the catalogue first and the model
118
+ * is spent only on what they cannot settle, which on a local box is the difference between a run
119
+ * starting now and starting in a few seconds. The words have to be clear about it; see
120
+ * `preselectByKeywords` for what that means.
121
+ *
117
122
  * @param config The endpoint the preselector is reached through.
118
123
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
119
124
  * means by empty.
120
125
  * @param catalog The servers to choose from.
121
126
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
122
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
123
- * (`MAX_PER_LOAD`).
127
+ * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
128
+ * (`MAX_PER_LOAD`), and whether to try the words first.
124
129
  */
125
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, } = {}) {
130
+ export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
126
131
  if (!model || !catalog.some((server) => server.tools.length > 0))
127
132
  return [];
133
+ if (keywords) {
134
+ const guess = preselectByKeywords(catalog, prompt, {
135
+ maxPerLoad,
136
+ ...(keywords === true ? {} : keywords),
137
+ });
138
+ if (guess.confident) {
139
+ onNotice?.(`chose ${guess.names.length} tool${guess.names.length === 1 ? "" : "s"} by name`);
140
+ return guess.names;
141
+ }
142
+ }
128
143
  const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
129
144
  return preselection(reply, catalog, maxPerLoad);
130
145
  }
package/dist/index.d.ts CHANGED
@@ -30,4 +30,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
30
30
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
31
31
  export { estimateTokens } from "./tokens.ts";
32
32
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
33
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, type ToolOrder, } from "./tool-loading.ts";
33
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -28,4 +28,4 @@ export { streamTurn, } from "./stream.js";
28
28
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
29
29
  export { estimateTokens } from "./tokens.js";
30
30
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
31
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
31
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
@@ -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
@@ -277,6 +277,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
277
277
  - `catalogPrompt` — The catalogue block appended to the system prompt.
278
278
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
279
279
  - `inCatalog` — Whether the catalogue holds a tool by this name.
280
+ - `KEYWORD_DROPOFF` — How far the best unpicked tool must fall below the last picked one for the cut to count clean.
281
+ - `KEYWORD_MIN_SCORE` — The least a best match may score and still be acted on without a model.
282
+ - `KeywordPreselection` (type) — What `preselectByKeywords` found.
283
+ - `KeywordPreselectOptions` (type) — What `preselectByKeywords` takes besides the catalogue and the request.
280
284
  - `LOAD_TOOLS` — On-demand tool loading.
281
285
  - `LOAD_TOOLS_DEFINITION` — One object for the life of the process — the agent loop asks for it on every iteration.
282
286
  - `loadedTools` — A tool array with newly loaded definitions appended, in the order they were loaded.
@@ -286,8 +290,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
286
290
  - `orderTools` — The tool array in a stable order, so the same set of tools renders the same way twice.
287
291
  - `PRESELECT_SCHEMA` — The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
288
292
  - `PRESELECT_SYSTEM` — The preselection system prompt at the default cap, for a caller that never changes it.
293
+ - `preselectByKeywords` — The tools a request's own words point at, ranked, and whether they point clearly enough.
289
294
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
290
295
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
291
296
  - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
292
297
  - `requestedNames` — `load_tools` arguments, defensively — a model may send a bare string or a nested object.
298
+ - `ToolMatch` (type) — One tool's score against a request.
293
299
  - `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.17.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",