@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.
- package/README.md +80 -1
- package/dist/agent-loop.d.ts +15 -4
- package/dist/agent-loop.js +19 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/spec.d.ts +319 -0
- package/dist/spec.js +662 -0
- package/dist/tool-loading.d.ts +73 -0
- package/dist/tool-loading.js +125 -0
- package/llms.txt +34 -0
- package/package.json +5 -1
package/dist/tool-loading.d.ts
CHANGED
|
@@ -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;
|
package/dist/tool-loading.js
CHANGED
|
@@ -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.
|
|
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": [
|