@cubicecho/agent-core 2.15.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. |
@@ -132,28 +132,49 @@ again, with the setting still reading `high` and nothing anywhere saying it had
132
132
  So they hang off the endpoint under the name the endpoint knows the model by. Pass `model` and
133
133
  the same loop answers both levels; leave it out and nothing changes.
134
134
 
135
- A refusal of the *value* is not one of these, however alike the two read: an effort off a list
136
- this package does not know, a `max_tokens` larger than the model's ceiling, a temperature out of
137
- range. Dropping the field answers those too — at the model's own default, latched for the rest of
138
- the process, with the settings row still reading what was typed and nothing saying it had stopped
139
- meaning it. They are passed to the caller instead, where whoever typed the number can see it.
135
+ A refusal of the *value* is not one of these, however alike the two read: a `max_tokens` larger
136
+ than the model's ceiling, a temperature out of range. Dropping the field answers those too — at
137
+ the model's own default, latched for the rest of the process, with the settings row still reading
138
+ what was typed and nothing saying it had stopped meaning it. They are passed to the caller
139
+ instead, where whoever typed the number can see it.
140
+
141
+ The effort is the exception, because there the model has said what it *would* take:
142
+ `Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values
143
+ are: 'minimal', 'low', 'medium', and 'high'.` `negotiate` latches the rung that was refused and
144
+ re-sends at the cheapest one above it, which is a request rather than a failure. Where the refusal
145
+ lists nothing it walks `EFFORT_LADDER` — `none`, `minimal`, `low`, `medium`, `high` — a rung per
146
+ refusal. Both latch per `(endpoint, model)` and ride in the snapshot, so a restart does not walk
147
+ the ladder again.
148
+
149
+ Only ever upward, and only from a value on the ladder. Answering a refused `xhigh` with `high`
150
+ would quietly reason less than whoever typed it asked for; stepping up from `none` only costs
151
+ tokens, and says so in a notice. When the ladder runs out, or the value is not on it, the refusal
152
+ goes to the caller as it did before.
153
+
154
+ `effortFor(model, asked)` is what a body builder calls to get the value to send — `buildBody` and
155
+ the side tasks both do — and it answers the empty string for a model that takes no effort at all,
156
+ for `"off"` and for an absent setting.
140
157
 
141
158
  ```ts
142
- const turn = await negotiate(supports, (supports, produced, model) =>
143
- streamTurn(
144
- getClient(config),
145
- {
146
- model: name, messages, stream: true,
147
- // Each rebuilt per attempt from what this model has already refused.
148
- ...(model?.reasoningEffort ? { reasoning_effort: effort } : {}),
149
- ...(model?.legacyTokenLimit === false
150
- ? { max_completion_tokens: limit }
151
- : { max_tokens: limit }),
152
- ...(model?.chosenTemperature ? { temperature } : {}),
153
- tools: supports.strictSchemas ? declared : relaxTools(declared),
154
- },
155
- { produced, signal },
156
- ),
159
+ const turn = await negotiate(
160
+ supports,
161
+ (supports, produced, model) => {
162
+ // Each rebuilt per attempt from what this model has already refused.
163
+ const asked = effortFor(model, effort);
164
+ return streamTurn(
165
+ getClient(config),
166
+ {
167
+ model: name, messages, stream: true,
168
+ ...(asked ? { reasoning_effort: asked } : {}),
169
+ ...(model?.legacyTokenLimit === false
170
+ ? { max_completion_tokens: limit }
171
+ : { max_tokens: limit }),
172
+ ...(model?.chosenTemperature ? { temperature } : {}),
173
+ tools: supports.strictSchemas ? declared : relaxTools(declared),
174
+ },
175
+ { produced, signal },
176
+ );
177
+ },
157
178
  { model: name },
158
179
  );
159
180
  ```
@@ -420,6 +441,33 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
420
441
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
421
442
  and everything is back from the second step on.
422
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
+
423
471
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
424
472
  continued first. `continueTurn` is the same thing for a caller with its own loop:
425
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.
@@ -18,7 +18,8 @@ import { type ToolOrder } from "./tool-loading.ts";
18
18
  * one has to read the same — which is the test one of the three copies had inverted.
19
19
  *
20
20
  * @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
21
- * absent or `"off"` sends no effort.
21
+ * absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
22
+ * cheapest it takes by `effortFor`.
22
23
  * @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
23
24
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
24
25
  * that has refused nothing.
@@ -71,19 +72,30 @@ export declare function resolveApiKey(own: {
71
72
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
72
73
  * since a side task is never worth failing the run. A stop still throws.
73
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
+ *
74
80
  * @param config The endpoint the preselector is reached through.
75
81
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
76
82
  * means by empty.
77
83
  * @param catalog The servers to choose from.
78
84
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
79
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
80
- * (`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.
81
87
  */
82
- 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, }?: {
83
89
  signal?: AbortSignal;
84
90
  onNotice?: (message: string) => void;
85
91
  maxTokens?: number;
86
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;
87
99
  }): Promise<string[]>;
88
100
  /** One call the model made, as `dispatch` is handed it. */
89
101
  export interface ToolCallRequest {
@@ -1,5 +1,5 @@
1
1
  import { charsPerTokenFor } from "./calibration.js";
2
- import { capabilitiesFor } from "./capabilities.js";
2
+ import { capabilitiesFor, effortFor, } from "./capabilities.js";
3
3
  import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
4
  import { continueTurn } from "./continuation.js";
5
5
  import { errorMessage } from "./errors.js";
@@ -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.
@@ -35,7 +35,8 @@ const RESERVED = new Set(["model", "messages", "stream", "tools"]);
35
35
  * one has to read the same — which is the test one of the three copies had inverted.
36
36
  *
37
37
  * @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
38
- * absent or `"off"` sends no effort.
38
+ * absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
39
+ * cheapest it takes by `effortFor`.
39
40
  * @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
40
41
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
41
42
  * that has refused nothing.
@@ -51,7 +52,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
51
52
  const declared = supports.strictSchemas
52
53
  ? sanitizeTools(sorted)
53
54
  : relaxTools(sanitizeTools(sorted));
54
- const effort = config.reasoningEffort;
55
+ const effort = effortFor(refused, config.reasoningEffort);
55
56
  const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
56
57
  return {
57
58
  ...(config.maxTokens > 0
@@ -60,9 +61,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
60
61
  : { max_tokens: config.maxTokens }
61
62
  : {}),
62
63
  ...(refused?.chosenTemperature === false ? {} : { temperature: config.temperature }),
63
- ...(effort && effort !== "off" && refused?.reasoningEffort !== false
64
- ? { reasoning_effort: effort }
65
- : {}),
64
+ ...(effort ? { reasoning_effort: effort } : {}),
66
65
  ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
67
66
  ...Object.fromEntries(extra),
68
67
  model: config.model,
@@ -115,17 +114,32 @@ export function resolveApiKey(own, inherited, env = process.env) {
115
114
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
116
115
  * since a side task is never worth failing the run. A stop still throws.
117
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
+ *
118
122
  * @param config The endpoint the preselector is reached through.
119
123
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
120
124
  * means by empty.
121
125
  * @param catalog The servers to choose from.
122
126
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
123
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
124
- * (`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.
125
129
  */
126
- 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, } = {}) {
127
131
  if (!model || !catalog.some((server) => server.tools.length > 0))
128
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
+ }
129
143
  const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
130
144
  return preselection(reply, catalog, maxPerLoad);
131
145
  }
@@ -93,6 +93,22 @@ export interface ModelCapabilities {
93
93
  * endpoint's because one key reaches models that differ here, the way they differ on effort.
94
94
  */
95
95
  structuredOutput: boolean;
96
+ /**
97
+ * Efforts this model refused by value rather than by field — a request that named `none` on a
98
+ * model whose list starts at `minimal`. Only ever grows, which is this set's way of latching.
99
+ *
100
+ * Separate from `reasoningEffort` because the two refusals mean opposite things: that one says
101
+ * the model cannot reason and the field must go, this one says it reasons and was handed an
102
+ * effort off a list this package does not know. Dropping the field there would run at the
103
+ * model's own default, which is neither what the caller asked for nor something it can see.
104
+ */
105
+ refusedEfforts: Set<string>;
106
+ /**
107
+ * The efforts a refusal published as this model's, in ladder order, filtered to the ones
108
+ * `EFFORT_LADDER` can place. Absent until a refusal lists them, and a model that lists nothing
109
+ * is walked up the ladder a rung per refusal instead.
110
+ */
111
+ supportedEfforts?: string[];
96
112
  /**
97
113
  * Continues a trailing assistant message rather than answering afresh, which `continueTurn`
98
114
  * relies on. llama.cpp renders one as a prefill and picks up mid-word; hosted OpenAI takes the
@@ -170,6 +186,30 @@ export declare function resetCapabilities(endpoint?: {
170
186
  * @returns How many endpoints were forgotten.
171
187
  */
172
188
  export declare function expireCapabilities(maxAgeMs: number, now?: number): number;
189
+ /**
190
+ * The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
191
+ *
192
+ * Not a list of what any model takes — `medium` is refused by a model whose list is
193
+ * `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
194
+ * OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
195
+ * placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
196
+ * refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
197
+ * typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
198
+ */
199
+ export declare const EFFORT_LADDER: readonly ["none", "minimal", "low", "medium", "high"];
200
+ /**
201
+ * What to actually put in `reasoning_effort` for a model that has refused the value asked for.
202
+ *
203
+ * The caller's own value, until this model has said that value is not one of its own; then the
204
+ * cheapest it will take that is at least as much deliberation. A model that takes no effort at
205
+ * all, and an absent or `"off"` setting, both answer the empty string, which is the body
206
+ * builders' signal to send no field.
207
+ *
208
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
209
+ * has refused nothing.
210
+ * @param asked What the config asks for. `"off"` and absent mean no effort.
211
+ */
212
+ export declare function effortFor(refused: ModelCapabilities | undefined, asked: string | undefined): string;
173
213
  /** What `negotiate` takes besides the request. All optional. */
174
214
  export interface NegotiateOptions {
175
215
  /**
@@ -56,6 +56,7 @@ export function modelCapabilitiesFor(supports, model) {
56
56
  chosenTemperature: true,
57
57
  refusedFields: new Set(),
58
58
  structuredOutput: true,
59
+ refusedEfforts: new Set(),
59
60
  assistantPrefill: true,
60
61
  };
61
62
  supports.models.set(model, known);
@@ -154,7 +155,8 @@ const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(de
154
155
  * perfectly well; it was handed an effort off a list this package does not know. Dropping the
155
156
  * field succeeds, at the model's own default effort, which is neither what the caller asked for
156
157
  * nor something it can see — and the drop latches, so every later turn on that model reasons at
157
- * the default with the setting still reading what the operator typed.
158
+ * the default with the setting still reading what the operator typed. `stepEffort` answers this
159
+ * one instead, by naming an effort the model does take.
158
160
  */
159
161
  const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
160
162
  /**
@@ -165,6 +167,127 @@ const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
165
167
  * latching.
166
168
  */
167
169
  const rejectsEffort = (detail) => /reasoning_effort/i.test(detail) && !REFUSED_VALUE.test(detail);
170
+ /**
171
+ * The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
172
+ *
173
+ * Not a list of what any model takes — `medium` is refused by a model whose list is
174
+ * `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
175
+ * OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
176
+ * placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
177
+ * refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
178
+ * typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
179
+ */
180
+ export const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high"];
181
+ const rankOf = (effort) => EFFORT_LADDER.indexOf(effort.toLowerCase());
182
+ /**
183
+ * The efforts a refusal lists as this model's: `Supported values are: 'minimal', 'low', 'medium',
184
+ * and 'high'.` Quoted or bare, separated by commas and a trailing `and` or `or`.
185
+ */
186
+ function listedEfforts(detail) {
187
+ const listed = detail.match(/supported values(?:\s+\w+)?\s*(?:are|is|include)?\s*:?\s*([^\n.]+)/i)?.[1];
188
+ if (!listed)
189
+ return undefined;
190
+ const values = listed
191
+ .split(/,|\band\b|\bor\b/)
192
+ .map((value) => value
193
+ .trim()
194
+ .replace(/^['"`]+|['"`]+$/g, "")
195
+ .toLowerCase())
196
+ .filter((value) => rankOf(value) >= 0);
197
+ return values.length ? values : undefined;
198
+ }
199
+ /**
200
+ * Which effort a refusal says was refused, in the two shapes the wording takes: the field quoted
201
+ * then `does not support 'none'`, and `reasoning_effort: none`.
202
+ *
203
+ * Read rather than remembered, because `negotiate` builds no request and so does not know what
204
+ * went out — and a proxy that rewrites the value before passing it on is refusing the one it sent
205
+ * rather than the one it was given. A refusal that names no value steps nothing: guessing which
206
+ * rung was refused is how a ladder walks past the value that would have worked.
207
+ *
208
+ * @param detail The refusal.
209
+ * @param supported What the same refusal listed, which the value cannot be one of.
210
+ */
211
+ function refusedEffortValue(detail, supported = []) {
212
+ const found = detail.match(/reasoning_effort['"`]?\s*(?:does not support|is not supported with|:)\s*['"`]?([\w-]+)/i)?.[1];
213
+ const value = found?.toLowerCase();
214
+ return value && !supported.includes(value) ? value : undefined;
215
+ }
216
+ /**
217
+ * The cheapest effort above this one that the model has not refused, or `undefined` when the
218
+ * ladder is out of rungs.
219
+ *
220
+ * Above, never below: see `EFFORT_LADDER`. Where a refusal published a list that is the whole of
221
+ * what is tried, so the step lands in one request; where it published none the ladder is walked a
222
+ * rung at a time, each refusal latching the rung it named.
223
+ */
224
+ function nextEffort(refused, asked) {
225
+ const rank = rankOf(asked);
226
+ if (rank < 0)
227
+ return undefined;
228
+ let best;
229
+ for (const value of refused.supportedEfforts ?? EFFORT_LADDER) {
230
+ const at = rankOf(value);
231
+ if (at <= rank || refused.refusedEfforts.has(value))
232
+ continue;
233
+ if (best === undefined || at < rankOf(best))
234
+ best = value;
235
+ }
236
+ return best;
237
+ }
238
+ /**
239
+ * What to actually put in `reasoning_effort` for a model that has refused the value asked for.
240
+ *
241
+ * The caller's own value, until this model has said that value is not one of its own; then the
242
+ * cheapest it will take that is at least as much deliberation. A model that takes no effort at
243
+ * all, and an absent or `"off"` setting, both answer the empty string, which is the body
244
+ * builders' signal to send no field.
245
+ *
246
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
247
+ * has refused nothing.
248
+ * @param asked What the config asks for. `"off"` and absent mean no effort.
249
+ */
250
+ export function effortFor(refused, asked) {
251
+ if (!asked || asked === "off")
252
+ return "";
253
+ if (!refused)
254
+ return asked;
255
+ if (!refused.reasoningEffort)
256
+ return "";
257
+ const known = refused.supportedEfforts;
258
+ const listed = known ? known.includes(asked.toLowerCase()) : true;
259
+ if (listed && !refused.refusedEfforts.has(asked.toLowerCase()))
260
+ return asked;
261
+ return nextEffort(refused, asked) ?? asked;
262
+ }
263
+ /**
264
+ * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
265
+ * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
266
+ *
267
+ * Pure, so `negotiate` can work out whether this refusal is one of its own before deciding to
268
+ * answer it, and latch only in the branch it takes.
269
+ */
270
+ function planEffortStep(detail, refused) {
271
+ if (!refused.reasoningEffort)
272
+ return undefined;
273
+ if (!/reasoning_effort/i.test(detail) || !REFUSED_VALUE.test(detail))
274
+ return undefined;
275
+ const supported = listedEfforts(detail);
276
+ const value = refusedEffortValue(detail, supported);
277
+ if (value === undefined)
278
+ return undefined;
279
+ // Something has to be new, or a server that answers every request by naming an effort nobody
280
+ // sent would have `negotiate` re-send for as long as it kept saying it.
281
+ const fresh = supported?.join() !== refused.supportedEfforts?.join();
282
+ if (refused.refusedEfforts.has(value) && !fresh)
283
+ return undefined;
284
+ const next = nextEffort({
285
+ ...refused,
286
+ refusedEfforts: new Set(refused.refusedEfforts).add(value),
287
+ supportedEfforts: supported ?? refused.supportedEfforts,
288
+ }, value);
289
+ return next === undefined ? undefined : { value, next, ...(supported ? { supported } : {}) };
290
+ }
168
291
  /**
169
292
  * Read only alongside the name it is asking for: `'max_tokens' is not supported with this model.
170
293
  * Use 'max_completion_tokens' instead.`
@@ -280,6 +403,7 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
280
403
  if (produced.any)
281
404
  throw error;
282
405
  const detail = errorMessage(error);
406
+ const stepped = named && planEffortStep(detail, named.refused);
283
407
  if (supports.strictSchemas && isGrammarError(detail)) {
284
408
  supports.strictSchemas = false;
285
409
  onNotice?.("server could not build a grammar; retrying without pattern/format");
@@ -292,6 +416,12 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
292
416
  named.refused.reasoningEffort = false;
293
417
  onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
294
418
  }
419
+ else if (named && stepped) {
420
+ named.refused.refusedEfforts.add(stepped.value);
421
+ if (stepped.supported)
422
+ named.refused.supportedEfforts = stepped.supported;
423
+ onNotice?.(`${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`);
424
+ }
295
425
  else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
296
426
  named.refused.legacyTokenLimit = false;
297
427
  onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
- export { type Capabilities, capabilitiesFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
+ export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
15
15
  export type { CatalogServer } from "./catalog.ts";
16
16
  export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
17
17
  export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
@@ -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
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
14
- export { capabilitiesFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
14
+ export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
15
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
16
16
  export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
17
17
  export { continueTurn, isContinuable, } from "./continuation.js";
@@ -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";
package/dist/side-task.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import OpenAI from "openai";
2
- import { capabilitiesFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
2
+ import { capabilitiesFor, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
3
3
  import { endpointId, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { isTransient } from "./retry.js";
@@ -85,7 +85,11 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
85
85
  // Whether the last request carried an effort, which `negotiate` decides and not this function.
86
86
  let sentEffort = false;
87
87
  const send = (hints, effort, supports, refused) => {
88
- sentEffort = effort && refused?.reasoningEffort !== false;
88
+ // `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
89
+ // models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
90
+ // cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
91
+ const asked = effort ? effortFor(refused, "none") : "";
92
+ sentEffort = asked !== "";
89
93
  return getClient(config).chat.completions.create({
90
94
  model,
91
95
  // The reasoning models want the ceiling spelled the other way, and they are exactly the
@@ -103,7 +107,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
103
107
  ...(hints ? NO_THINKING : {}),
104
108
  // Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
105
109
  // effort, and the two latches would otherwise contradict each other.
106
- ...(sentEffort ? { reasoning_effort: "none" } : {}),
110
+ ...(sentEffort ? { reasoning_effort: asked } : {}),
107
111
  ...(format && refused ? format(supports, refused) : {}),
108
112
  }, { signal });
109
113
  };
@@ -22,6 +22,14 @@ export interface ModelSnapshot {
22
22
  assistantPrefill: boolean;
23
23
  /** Takes the no-thinking hints `ask` sends. */
24
24
  thinkingHints: boolean;
25
+ /**
26
+ * Efforts refused by value rather than by field, so a restart does not spend a request per rung
27
+ * walking the ladder again. Absent in a snapshot taken before they were latched, which reads as
28
+ * none refused.
29
+ */
30
+ refusedEfforts?: string[];
31
+ /** The efforts a refusal published as this model's, in ladder order. Absent is none published. */
32
+ supportedEfforts?: string[];
25
33
  }
26
34
  /** What one endpoint refused, and under it what each of its models did. */
27
35
  export interface EndpointSnapshot {
package/dist/snapshot.js CHANGED
@@ -18,6 +18,7 @@ const optimisticModel = () => ({
18
18
  structuredOutput: true,
19
19
  assistantPrefill: true,
20
20
  thinkingHints: true,
21
+ refusedEfforts: [],
21
22
  });
22
23
  const refusedAnything = (model) => !model.reasoningEffort ||
23
24
  !model.legacyTokenLimit ||
@@ -25,7 +26,8 @@ const refusedAnything = (model) => !model.reasoningEffort ||
25
26
  !model.thinkingHints ||
26
27
  !model.structuredOutput ||
27
28
  !model.assistantPrefill ||
28
- model.refusedFields.length > 0;
29
+ model.refusedFields.length > 0 ||
30
+ (model.refusedEfforts?.length ?? 0) > 0;
29
31
  /**
30
32
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
31
33
  *
@@ -59,6 +61,10 @@ export function exportCapabilities() {
59
61
  refusedFields: [...refused.refusedFields].sort(),
60
62
  structuredOutput: refused.structuredOutput,
61
63
  assistantPrefill: refused.assistantPrefill,
64
+ refusedEfforts: [...refused.refusedEfforts].sort(),
65
+ // Only alongside a refusal, since on its own a published list latches nothing: the model
66
+ // named it while refusing a rung, and that rung is in `refusedEfforts`.
67
+ ...(refused.supportedEfforts ? { supportedEfforts: [...refused.supportedEfforts] } : {}),
62
68
  };
63
69
  if (refusedAnything(model))
64
70
  models[name] = model;
@@ -128,6 +134,19 @@ export function importCapabilities(snapshot) {
128
134
  refused.structuredOutput = false;
129
135
  if (model.assistantPrefill === false)
130
136
  refused.assistantPrefill = false;
137
+ if (Array.isArray(model.refusedEfforts)) {
138
+ for (const effort of model.refusedEfforts) {
139
+ if (typeof effort === "string")
140
+ refused.refusedEfforts.add(effort);
141
+ }
142
+ }
143
+ // Replaced rather than merged: two lists of what one model takes are two readings of the
144
+ // same fact, and the stored one is at least as recent as an empty absent.
145
+ if (Array.isArray(model.supportedEfforts)) {
146
+ const listed = model.supportedEfforts.filter((value) => typeof value === "string");
147
+ if (listed.length)
148
+ refused.supportedEfforts = listed;
149
+ }
131
150
  if (Array.isArray(model.refusedFields)) {
132
151
  for (const field of model.refusedFields) {
133
152
  if (typeof field === "string")
@@ -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
@@ -38,6 +38,8 @@ What an endpoint turned out not to support, and answering it when it says so.
38
38
 
39
39
  - `Capabilities` (type) — What one endpoint turned out not to support.
40
40
  - `capabilitiesFor` — What this endpoint is known not to support.
41
+ - `EFFORT_LADDER` — The efforts this package can place, cheapest first.
42
+ - `effortFor` — What to actually put in `reasoning_effort` for a model that has refused the value asked for.
41
43
  - `expireCapabilities` — Forgets every endpoint whose entry is older than this, so the next request finds out again.
42
44
  - `ModelCapabilities` (type) — What one model on that endpoint turned out not to support.
43
45
  - `modelCapabilitiesFor` — What this model on this endpoint is known not to support.
@@ -275,6 +277,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
275
277
  - `catalogPrompt` — The catalogue block appended to the system prompt.
276
278
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
277
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.
278
284
  - `LOAD_TOOLS` — On-demand tool loading.
279
285
  - `LOAD_TOOLS_DEFINITION` — One object for the life of the process — the agent loop asks for it on every iteration.
280
286
  - `loadedTools` — A tool array with newly loaded definitions appended, in the order they were loaded.
@@ -284,8 +290,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
284
290
  - `orderTools` — The tool array in a stable order, so the same set of tools renders the same way twice.
285
291
  - `PRESELECT_SCHEMA` — The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
286
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.
287
294
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
288
295
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
289
296
  - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
290
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.
291
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.15.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",