@cubicecho/agent-core 4.13.0 → 4.14.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
@@ -768,8 +768,8 @@ distinctive than a word the catalogue shares has to have matched, and the tools
768
768
  have to score well below the ones it kept — a hit just under the line scoring nearly as much as
769
769
  one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
770
770
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
771
- tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
772
- preselection at all, words included. `preselect` also takes a `temperature` and a
771
+ tools from one whose words are not in the catalogue. An empty model still means no preselection
772
+ at all, words included. `preselect` also takes a `temperature` and a
773
773
  `reasoningEffort` and an `extraBody`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
774
774
  itself — see "Its side tasks" below.
775
775
 
@@ -884,10 +884,12 @@ as the host passed them, this one as typed — which is why the snippet stores o
884
884
  without waiting; a host that stores those notes with the turn, or would otherwise exit before the
885
885
  turn is remembered, awaits it once the answer is out.
886
886
 
887
- The loop gathers before its first request, and nothing of the host's can run beside that. A host
888
- with pre-turn work to overlap with the hooks — compaction, a preselection — calls `gather` itself,
889
- applies `withContext` to the current question as well, calls `notify` after, and passes no `hooks`
890
- (see [Hooks](#hooks)).
887
+ The loop gathers before its first request, beside its own preselection when `preselect` asked for
888
+ one, and nothing else of the host's can run beside those. A host with pre-turn work to overlap with
889
+ the hooks — a compaction — calls `gather` itself, applies `withContext` to the current question as
890
+ well, calls `notify` after, and passes no `hooks` (see [Hooks](#hooks)). Such a host passes the
891
+ question as it was typed as `preselectQuery` too, or the preselector is shown the context in front
892
+ of it and picks tools for what was recalled rather than what was asked.
891
893
 
892
894
  A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
893
895
  readable history — gets each message as the loop appends it, and the run as it stood when the loop
@@ -1488,16 +1490,31 @@ with `tasks.compaction` writing the summary and no falling back to the main mode
1488
1490
  are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
1489
1491
  a second request.
1490
1492
 
1493
+ The preselector falls back to the main model. With neither `tasks.toolSelect` nor
1494
+ `toolSelectModel` naming one, `preselect: true` asks `config.model` on the agent's own endpoint at
1495
+ `preselect`'s defaults: a host that opted in has asked for a preselection, and an empty name is a
1496
+ setting nobody filled in. Compaction has no such fallback. A host that wants no preselection passes
1497
+ no `preselect`. A spec layer that declines the task with `model: ""` leaves it absent from the
1498
+ resolved agent, as one nobody configured is, so under `preselect: true` the main model is asked
1499
+ there too.
1500
+
1501
+ What it asks about is the question as written: the last user message of `messages`, which the hooks'
1502
+ context never reaches because the loop puts that on the request. A host that put context on the
1503
+ question itself passes `preselectQuery`. The preselection and the hooks' `gather` run side by side,
1504
+ since neither reads the other, so asking for both costs the first request one round trip rather
1505
+ than two.
1506
+
1491
1507
  What the loop picked it says twice. A `notice` event names the tools before the first `turn` event
1492
1508
  — `PRESELECTED_NOTICE` and then the names, comma-separated — and the result's `preselected` is the
1493
1509
  shortlist the run opened with, the host's own where it gave one and empty where nothing was picked.
1494
1510
  A run that fails carries it too, beside `loaded` and `used`.
1495
1511
 
1496
- An empty `toolSelectModel` means no preselection, and says nothing about it. A host whose settings
1497
- read `""` as "use the run's model" has to say so in the config it builds:
1512
+ Called directly, `preselect` reads an empty model as no preselection and says nothing about it; it
1513
+ is only the loop that falls back. A host calling it itself whose settings read `""` as "use the
1514
+ run's model" says so in the call:
1498
1515
 
1499
1516
  ```ts
1500
- const config = { ...settings, toolSelectModel: settings.toolSelectModel || settings.model };
1517
+ await preselect(config, settings.toolSelectModel || settings.model, catalog, prompt, options);
1501
1518
  ```
1502
1519
 
1503
1520
  ```ts
@@ -148,7 +148,10 @@ export type ToolDiscovery = (typeof ToolDiscovery)[keyof typeof ToolDiscovery];
148
148
  export interface ToolPolicy {
149
149
  /** How tool definitions reach the model. */
150
150
  toolDiscovery: ToolDiscovery;
151
- /** The model that does the preselection pass. Empty means don't preselect. */
151
+ /**
152
+ * The model that does the preselection pass. Empty means don't preselect, to `preselect` itself;
153
+ * `runAgentLoop`, asked to preselect, reads empty as the run's own `model`.
154
+ */
152
155
  toolSelectModel: string;
153
156
  /** Hard stop on runaway tool loops. */
154
157
  maxToolIterations: number;
@@ -43,9 +43,10 @@ export interface AgentLoopRequest {
43
43
  * The hooks a loop runs around one question. See `hooks.ts`.
44
44
  *
45
45
  * @remarks
46
- * The loop gathers before its first request and nothing else can run beside that, so a host with
47
- * pre-turn work of its own to overlap with the hooks — compaction, a preselection — calls `gather`
48
- * itself, puts the context on with `withContext`, calls `notify` after, and passes no `hooks`.
46
+ * The loop gathers before its first request, beside its own preselection where it was asked for
47
+ * one, and nothing else runs beside the two. A host with pre-turn work of its own to overlap with
48
+ * the hooks — a compaction — calls `gather` itself, puts the context on with `withContext`, calls
49
+ * `notify` after, passes no `hooks`, and hands `preselectQuery` the question as it was written.
49
50
  */
50
51
  export interface AgentLoopHooks {
51
52
  run: HookRunner;
@@ -109,7 +110,8 @@ export interface AgentLoopOptions {
109
110
  * @remarks
110
111
  * `tasks` is a resolved agent's, and is read only for the side tasks the loop was asked to run:
111
112
  * `tasks.toolSelect` under `preselect` and `tasks.compaction` under `compact`. `toolSelectModel`
112
- * is the flattened spelling of the first, for a host that is not on the spec.
113
+ * is the flattened spelling of the first, for a host that is not on the spec. With neither
114
+ * naming a model, `preselect` asks `model` itself; `compact` never does.
113
115
  */
114
116
  config: Endpoint & ModelParams & Pick<ToolPolicy, 'maxToolIterations'> & Partial<Pick<ToolPolicy, 'toolDiscovery' | 'toolSelectModel'>> & Partial<RetryPolicy> & {
115
117
  contextLength?: number;
@@ -200,20 +202,37 @@ export interface AgentLoopOptions {
200
202
  * The preselector is `config.tasks.toolSelect` with everything it states — its endpoint, model,
201
203
  * ceiling, temperature and reasoning effort, keyed as `taskCall` keys a task against `config` —
202
204
  * and otherwise `config.toolSelectModel` on the config's own endpoint at `preselect`'s defaults.
203
- * What it is asked about is the text of the last user message. Nothing is asked in eager mode,
204
- * with neither preselector named, with no user message, or when `preselected` is given — an
205
- * empty one included, which is how a host says it has already decided.
205
+ * With neither naming a model it is `config.model` itself, on the same terms as
206
+ * `toolSelectModel`: a host that opted in has asked for a preselection, and an empty name is a
207
+ * setting nobody filled in rather than a refusal. A host that wants none passes no `preselect`.
208
+ *
209
+ * What it is asked about is `preselectQuery`, and otherwise the text of the last user message as
210
+ * the host gave it — the hooks' context is put on the request, never on `messages`, so the
211
+ * tools are picked on what was asked rather than on what was recalled. Nothing is asked in eager
212
+ * mode, with no question, or when `preselected` is given — an empty one included, which is how
213
+ * a host says it has already decided.
206
214
  *
207
215
  * `true` takes `preselect`'s defaults; an object passes `keywords` and `maxPerLoad` through.
208
216
  * Its notices arrive as `notice` events, and a preselection that fails picks nothing rather
209
217
  * than ending the run. What it picked is a notice too, `PRESELECTED_NOTICE` and the names,
210
- * before the first `turn` event, and is the result's `preselected`. It runs before the hooks
211
- * gather, not beside them.
218
+ * before the first `turn` event, and is the result's `preselected`. It runs beside the hooks'
219
+ * `gather` rather than before it, since neither reads what the other returns, and the first
220
+ * request waits for both.
212
221
  */
213
222
  preselect?: boolean | {
214
223
  keywords?: boolean | KeywordPreselectOptions;
215
224
  maxPerLoad?: number;
216
225
  };
226
+ /**
227
+ * What the loop's own preselection is asked about, in place of the last user message's text.
228
+ * Read only under `preselect`.
229
+ *
230
+ * @remarks
231
+ * For a host that gathered its hooks itself and sent the question with `withContext` already on
232
+ * it: the loop cannot tell the context from the question, and a preselector shown both picks
233
+ * tools for whatever memory recalled. Blank asks nothing, as a blank question does.
234
+ */
235
+ preselectQuery?: string;
217
236
  /**
218
237
  * Has the loop fold the transcript itself before a step, once `planCompaction` says the window
219
238
  * is filling, with the summary written by `config.tasks.compaction` on its own settings. Off by
@@ -218,17 +218,18 @@ const userText = (message) => {
218
218
  * name on its own endpoint where it does not.
219
219
  *
220
220
  * @param options - The run's options. Read for the config, the catalogue, the signal, what
221
- * `preselect` passes through, and the last user message, whose text is what is asked about.
221
+ * `preselect` passes through, and `preselectQuery` or else the last user message, whose text is
222
+ * what is asked about.
222
223
  * @param notice - Hears the preselector's notices, a failure among them.
223
- * @returns The names picked. Empty where there is no user message or its text is blank — nothing
224
+ * @returns The names picked. Empty where the query is blank or there is no user message — nothing
224
225
  * is asked — and where the preselection picked nothing or failed. An aborted request still rejects.
225
226
  *
226
227
  * @remarks
227
- * `preselect` answers an empty model with nothing, so a config that names neither asks nothing.
228
+ * With no preselector named the main model is asked, on the config's own endpoint.
228
229
  */
229
230
  async function chooseTools(options, notice) {
230
231
  const { config, catalog = [], signal } = options;
231
- const prompt = userText(options.messages.findLast((message) => message.role === Role.User));
232
+ const prompt = options.preselectQuery ?? userText(options.messages.findLast((message) => message.role === Role.User));
232
233
  if (!prompt.trim()) {
233
234
  return [];
234
235
  }
@@ -239,7 +240,7 @@ async function chooseTools(options, notice) {
239
240
  };
240
241
  const selector = config.tasks?.toolSelect;
241
242
  if (!selector?.model) {
242
- return preselect(config, config.toolSelectModel ?? '', catalog, prompt, asked);
243
+ return preselect(config, config.toolSelectModel || config.model, catalog, prompt, asked);
243
244
  }
244
245
  const call = taskCall(selector, asked, config);
245
246
  return preselect(call.endpoint, call.model, catalog, prompt, call.options);
@@ -494,6 +495,18 @@ async function runSteps(options, standing, pin) {
494
495
  // The host's own fields win over the pin's, so an explicit `cache_prompt: false` stays false.
495
496
  const extraBody = pin ? { ...pin.fields, ...config.extraBody } : config.extraBody;
496
497
  const sending = { ...config, extraBody };
498
+ // Started before the preselection rather than after it: neither reads the other, and in a row
499
+ // they cost the first request two round trips where one will do. A failing runner is a note, but
500
+ // a host's `onNote` that throws rejects the gather, perhaps while the preselection is still out:
501
+ // handled at once so that is no unhandled rejection, and still thrown below, where it always was.
502
+ const gathering = hooks
503
+ ? gather(hooks.run, hooks.events ?? [HookEvent.BeforeTurn], hooks.context, {
504
+ signal,
505
+ onNote: hooks.onNote,
506
+ maxTokens: hooks.maxTokens,
507
+ })
508
+ : Promise.resolve({ context: '', notes: [] });
509
+ gathering.catch(() => undefined);
497
510
  const { proxied, onDemand, loaded, preselected } = await discovery(options, notice);
498
511
  const instructions = serverInstructions === false
499
512
  ? ''
@@ -531,13 +544,7 @@ async function runSteps(options, standing, pin) {
531
544
  // hooks' context with it, which is right.
532
545
  const question = messages.findLast((message) => message.role === Role.User);
533
546
  const questionAt = () => (question ? messages.indexOf(question) : -1);
534
- const gathered = hooks
535
- ? await gather(hooks.run, hooks.events ?? [HookEvent.BeforeTurn], hooks.context, {
536
- signal,
537
- onNote: hooks.onNote,
538
- maxTokens: hooks.maxTokens,
539
- })
540
- : { context: '', notes: [] };
547
+ const gathered = await gathering;
541
548
  // Read once, so what the result hands back is what every step of this run said.
542
549
  const preface = hooks?.preface ?? configureHooks().preface;
543
550
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.13.0",
3
+ "version": "4.14.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",