@cubicecho/agent-core 4.13.0 → 4.15.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,32 @@ 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, and calls `notify` after (see [Hooks](#hooks)). Such a host passes the question as it was
891
+ typed as `preselectQuery` too, or the preselector is shown the context in front of it and picks
892
+ tools for what was recalled rather than what was asked.
893
+
894
+ `hooks.fire` says which events the loop fires itself, and it fires no others: `sessionStart` and
895
+ `beforeTurn` gathered before the first request, `beforeCompact` beside each fold `compact` makes,
896
+ `afterTurn` once answered. Left out, it is all of them — `events` (`beforeTurn` by default), then
897
+ `afterTurn` and `beforeCompact` — which is what the loop always did. A host that fires the turn's
898
+ events around a step of its own still hands the loop its hooks for the fold:
899
+
900
+ ```ts
901
+ await runAgentLoop({
902
+ ...options,
903
+ compact: true,
904
+ hooks: { run, context, fire: [HookEvent.BeforeCompact] }, // beforeTurn and afterTurn are the host's
905
+ });
906
+ ```
907
+
908
+ An event left out of `fire` is one the result has nothing for: `afterTurn` is resolved and empty
909
+ without `afterTurn`, and `notes` and `context` are empty without an injecting event, since the
910
+ host's own `gather` and `notify` hold those. `sessionEnd` and `sessionDelete` have no point in a
911
+ loop and are ignored there. `events` is deprecated: it is the injecting half of `fire`, and is
912
+ ignored once `fire` is given.
891
913
 
892
914
  A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
893
915
  readable history — gets each message as the loop appends it, and the run as it stood when the loop
@@ -1488,16 +1510,31 @@ with `tasks.compaction` writing the summary and no falling back to the main mode
1488
1510
  are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
1489
1511
  a second request.
1490
1512
 
1513
+ The preselector falls back to the main model. With neither `tasks.toolSelect` nor
1514
+ `toolSelectModel` naming one, `preselect: true` asks `config.model` on the agent's own endpoint at
1515
+ `preselect`'s defaults: a host that opted in has asked for a preselection, and an empty name is a
1516
+ setting nobody filled in. Compaction has no such fallback. A host that wants no preselection passes
1517
+ no `preselect`. A spec layer that declines the task with `model: ""` leaves it absent from the
1518
+ resolved agent, as one nobody configured is, so under `preselect: true` the main model is asked
1519
+ there too.
1520
+
1521
+ What it asks about is the question as written: the last user message of `messages`, which the hooks'
1522
+ context never reaches because the loop puts that on the request. A host that put context on the
1523
+ question itself passes `preselectQuery`. The preselection and the hooks' `gather` run side by side,
1524
+ since neither reads the other, so asking for both costs the first request one round trip rather
1525
+ than two.
1526
+
1491
1527
  What the loop picked it says twice. A `notice` event names the tools before the first `turn` event
1492
1528
  — `PRESELECTED_NOTICE` and then the names, comma-separated — and the result's `preselected` is the
1493
1529
  shortlist the run opened with, the host's own where it gave one and empty where nothing was picked.
1494
1530
  A run that fails carries it too, beside `loaded` and `used`.
1495
1531
 
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:
1532
+ Called directly, `preselect` reads an empty model as no preselection and says nothing about it; it
1533
+ is only the loop that falls back. A host calling it itself whose settings read `""` as "use the
1534
+ run's model" says so in the call:
1498
1535
 
1499
1536
  ```ts
1500
- const config = { ...settings, toolSelectModel: settings.toolSelectModel || settings.model };
1537
+ await preselect(config, settings.toolSelectModel || settings.model, catalog, prompt, options);
1501
1538
  ```
1502
1539
 
1503
1540
  ```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,18 +43,41 @@ 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, and hands `preselectQuery` the question as it was written. It still passes
50
+ * `hooks` when the loop folds for it, with `fire` naming only `beforeCompact`, so what it fires
51
+ * itself is not fired a second time.
49
52
  */
50
53
  export interface AgentLoopHooks {
51
54
  run: HookRunner;
52
55
  /** What the hooks are told. `reply` and `turn` are filled in for `afterTurn`. */
53
56
  context: HookContext;
54
57
  /**
55
- * Run before the first request.
58
+ * The events the loop fires, and the only ones: `sessionStart` and `beforeTurn` gathered before
59
+ * the first request, in the order listed; `beforeCompact` beside each fold `compact` makes; and
60
+ * `afterTurn` once the run has its answer. `sessionEnd` and `sessionDelete` are the host's, since
61
+ * the loop has no point that is either, and are ignored here.
62
+ *
63
+ * @defaultValue `events`, then `afterTurn` and `beforeCompact` — everything the loop has a point
64
+ * for, as it fired before this existed.
65
+ *
66
+ * @remarks
67
+ * For a host that fires some of them around the loop rather than inside it: one that tells
68
+ * `beforeTurn` and `afterTurn` around a whole step of its own, and wants only the fold's
69
+ * `beforeCompact` from the loop, passes `[HookEvent.BeforeCompact]`. With `afterTurn` left out the
70
+ * result's `afterTurn` is resolved and empty, and `notes` and `context` are empty with no
71
+ * injecting event listed — the host's own `gather` and `notify` hold those instead.
72
+ */
73
+ fire?: readonly HookEvent[];
74
+ /**
75
+ * Run before the first request, when `fire` is not given. Ignored beside `fire`, which says the
76
+ * same thing with the other events in it.
56
77
  *
57
78
  * @defaultValue `[HookEvent.BeforeTurn]`
79
+ * @deprecated List them in `fire` instead, with `afterTurn` and `beforeCompact` where the loop
80
+ * should fire those too.
58
81
  */
59
82
  events?: readonly HookEvent[];
60
83
  /**
@@ -69,7 +92,7 @@ export interface AgentLoopHooks {
69
92
  * @defaultValue The preface `configureHooks` holds.
70
93
  */
71
94
  preface?: string;
72
- /** Hears each note, from before the request and from `afterTurn`. */
95
+ /** Hears each note, from before the request, from `beforeCompact` and from `afterTurn`. */
73
96
  onNote?: OnNote;
74
97
  /**
75
98
  * What the run's first message is numbered as in the uuids hooks are told — the host's stored
@@ -109,7 +132,8 @@ export interface AgentLoopOptions {
109
132
  * @remarks
110
133
  * `tasks` is a resolved agent's, and is read only for the side tasks the loop was asked to run:
111
134
  * `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.
135
+ * is the flattened spelling of the first, for a host that is not on the spec. With neither
136
+ * naming a model, `preselect` asks `model` itself; `compact` never does.
113
137
  */
114
138
  config: Endpoint & ModelParams & Pick<ToolPolicy, 'maxToolIterations'> & Partial<Pick<ToolPolicy, 'toolDiscovery' | 'toolSelectModel'>> & Partial<RetryPolicy> & {
115
139
  contextLength?: number;
@@ -200,20 +224,37 @@ export interface AgentLoopOptions {
200
224
  * The preselector is `config.tasks.toolSelect` with everything it states — its endpoint, model,
201
225
  * ceiling, temperature and reasoning effort, keyed as `taskCall` keys a task against `config` —
202
226
  * 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.
227
+ * With neither naming a model it is `config.model` itself, on the same terms as
228
+ * `toolSelectModel`: a host that opted in has asked for a preselection, and an empty name is a
229
+ * setting nobody filled in rather than a refusal. A host that wants none passes no `preselect`.
230
+ *
231
+ * What it is asked about is `preselectQuery`, and otherwise the text of the last user message as
232
+ * the host gave it — the hooks' context is put on the request, never on `messages`, so the
233
+ * tools are picked on what was asked rather than on what was recalled. Nothing is asked in eager
234
+ * mode, with no question, or when `preselected` is given — an empty one included, which is how
235
+ * a host says it has already decided.
206
236
  *
207
237
  * `true` takes `preselect`'s defaults; an object passes `keywords` and `maxPerLoad` through.
208
238
  * Its notices arrive as `notice` events, and a preselection that fails picks nothing rather
209
239
  * 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.
240
+ * before the first `turn` event, and is the result's `preselected`. It runs beside the hooks'
241
+ * `gather` rather than before it, since neither reads what the other returns, and the first
242
+ * request waits for both.
212
243
  */
213
244
  preselect?: boolean | {
214
245
  keywords?: boolean | KeywordPreselectOptions;
215
246
  maxPerLoad?: number;
216
247
  };
248
+ /**
249
+ * What the loop's own preselection is asked about, in place of the last user message's text.
250
+ * Read only under `preselect`.
251
+ *
252
+ * @remarks
253
+ * For a host that gathered its hooks itself and sent the question with `withContext` already on
254
+ * it: the loop cannot tell the context from the question, and a preselector shown both picks
255
+ * tools for whatever memory recalled. Blank asks nothing, as a blank question does.
256
+ */
257
+ preselectQuery?: string;
217
258
  /**
218
259
  * Has the loop fold the transcript itself before a step, once `planCompaction` says the window
219
260
  * is filling, with the summary written by `config.tasks.compaction` on its own settings. Off by
@@ -307,7 +348,8 @@ export interface AgentLoopOptions {
307
348
  */
308
349
  onToolResult?: (result: ToolCallResult) => void;
309
350
  /**
310
- * Hooks gathered onto the question before the first request, and told the reply after.
351
+ * Hooks gathered onto the question before the first request, and told the reply after — or
352
+ * whichever of those `fire` names.
311
353
  *
312
354
  * @remarks
313
355
  * Only this question is touched — the last user message of `messages` — and the context is on
@@ -447,12 +489,13 @@ export interface AgentLoopResult {
447
489
  * preselection picked it. Empty in eager mode, and where nothing was picked.
448
490
  */
449
491
  preselected: string[];
450
- /** The hooks' notes from before the first request. */
492
+ /** The hooks' notes from before the first request. Empty where `fire` lists no injecting event. */
451
493
  notes: HookNote[];
452
494
  /**
453
495
  * The `<context>` blocks the hooks added to this question, as `withContext` takes them. Empty
454
- * when no hook added any, and without `hooks`. Not in `messages`, so a host that wants the next
455
- * turn's request to begin with this one's stores it beside the question; see `hooks`.
496
+ * when no hook added any, without `hooks`, and where `fire` lists no injecting event. Not in
497
+ * `messages`, so a host that wants the next turn's request to begin with this one's stores it
498
+ * beside the question; see `hooks`.
456
499
  */
457
500
  context: string;
458
501
  /**
@@ -467,8 +510,9 @@ export interface AgentLoopResult {
467
510
  * The `afterTurn` hooks' notes, once they have run — failures only, as `notify` returns them.
468
511
  * The loop does not wait for it, so the answer is never held for a hook; a host that stores the
469
512
  * notes with the turn, or must not exit before the turn is remembered, awaits it. Each note
470
- * reaches `onNote` as well. Already resolved and empty without `hooks`, and rejects only if
471
- * `onNote` throws.
513
+ * reaches `onNote` as well. Already resolved and empty without `hooks`, and where `fire` leaves
514
+ * `afterTurn` out — a host that tells it itself has the notes from its own `notify`. Rejects only
515
+ * if `onNote` throws.
472
516
  */
473
517
  afterTurn: Promise<HookNote[]>;
474
518
  /**
@@ -6,7 +6,7 @@ import { charsPerTokenFor } from '../endpoint/calibration.js';
6
6
  import { capabilitiesFor, modelCapabilitiesFor, } from '../endpoint/capabilities.js';
7
7
  import { firstTokenMs, getClient, loadingMs, servedSlots, timeoutMs } from '../endpoint/client.js';
8
8
  import { CACHE_PROMPT_FIELD, SESSION_HEADER, SLOT_FIELD, slotAllocator } from '../endpoint/slots.js';
9
- import { HookEvent } from '../hooks/hook-events.js';
9
+ import { HookEvent, INJECT_EVENTS } from '../hooks/hook-events.js';
10
10
  import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from '../hooks/hooks.js';
11
11
  import { serverInstructionsPrompt } from '../tools/instructions.js';
12
12
  import { preselect } from '../tools/preselect.js';
@@ -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);
@@ -280,6 +281,7 @@ function ownSummariser(options, notice) {
280
281
  */
281
282
  async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice }) {
282
283
  const { config, hooks } = options;
284
+ const told = hooks && firing(hooks).beforeCompact ? hooks : undefined;
283
285
  const compactor = config.tasks?.compaction;
284
286
  const plan = planCompaction(transcript, {
285
287
  ...(typeof options.compact === 'object' ? options.compact : {}),
@@ -292,13 +294,37 @@ async function foldBeforeStep(transcript, reported, summarise, { options, charsP
292
294
  return transcript;
293
295
  }
294
296
  const folded = await compactTranscript(transcript, plan, summarise, {
295
- ...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote, offset: hooks.offset } } : {}),
297
+ ...(told ? { hooks: { run: told.run, context: told.context, onNote: told.onNote, offset: told.offset } } : {}),
296
298
  });
297
299
  if (folded !== transcript) {
298
300
  notice(`compacted ${plan.toSummarise.length} messages into a summary`);
299
301
  }
300
302
  return folded;
301
303
  }
304
+ /**
305
+ * Which events the loop fires for these hooks, read from `fire`, or from `events` without it.
306
+ *
307
+ * @param hooks - The run's hooks.
308
+ * @returns The events to gather before the first request, and whether `afterTurn` and
309
+ * `beforeCompact` are the loop's to fire.
310
+ *
311
+ * @remarks
312
+ * Without `fire`, `events` goes to `gather` as it was given, unfiltered, so a host that had put
313
+ * something odd in it sees what it saw before. With `fire`, only the injecting events are gathered:
314
+ * a list that names every event is the whole of the loop's firing, and `afterTurn` gathered before
315
+ * the request would be the hooks told a reply that does not exist yet.
316
+ */
317
+ function firing(hooks) {
318
+ if (!hooks.fire) {
319
+ return { before: hooks.events ?? [HookEvent.BeforeTurn], afterTurn: true, beforeCompact: true };
320
+ }
321
+ const fire = new Set(hooks.fire);
322
+ return {
323
+ before: [...fire].filter((event) => INJECT_EVENTS.has(event)),
324
+ afterTurn: fire.has(HookEvent.AfterTurn),
325
+ beforeCompact: fire.has(HookEvent.BeforeCompact),
326
+ };
327
+ }
302
328
  /**
303
329
  * Tells the `afterTurn` hooks what was answered, with the turn it closed where the question is
304
330
  * still in the transcript.
@@ -494,6 +520,19 @@ async function runSteps(options, standing, pin) {
494
520
  // The host's own fields win over the pin's, so an explicit `cache_prompt: false` stays false.
495
521
  const extraBody = pin ? { ...pin.fields, ...config.extraBody } : config.extraBody;
496
522
  const sending = { ...config, extraBody };
523
+ // Started before the preselection rather than after it: neither reads the other, and in a row
524
+ // they cost the first request two round trips where one will do. A failing runner is a note, but
525
+ // a host's `onNote` that throws rejects the gather, perhaps while the preselection is still out:
526
+ // handled at once so that is no unhandled rejection, and still thrown below, where it always was.
527
+ const fired = hooks && firing(hooks);
528
+ const gathering = hooks
529
+ ? gather(hooks.run, fired?.before ?? [], hooks.context, {
530
+ signal,
531
+ onNote: hooks.onNote,
532
+ maxTokens: hooks.maxTokens,
533
+ })
534
+ : Promise.resolve({ context: '', notes: [] });
535
+ gathering.catch(() => undefined);
497
536
  const { proxied, onDemand, loaded, preselected } = await discovery(options, notice);
498
537
  const instructions = serverInstructions === false
499
538
  ? ''
@@ -531,13 +570,7 @@ async function runSteps(options, standing, pin) {
531
570
  // hooks' context with it, which is right.
532
571
  const question = messages.findLast((message) => message.role === Role.User);
533
572
  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: [] };
573
+ const gathered = await gathering;
541
574
  // Read once, so what the result hands back is what every step of this run said.
542
575
  const preface = hooks?.preface ?? configureHooks().preface;
543
576
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
@@ -722,7 +755,7 @@ async function runSteps(options, standing, pin) {
722
755
  if (isAnswer || ended) {
723
756
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
724
757
  // Handed back instead, for a host that wants its notes.
725
- const afterTurn = hooks ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
758
+ const afterTurn = hooks && fired?.afterTurn ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
726
759
  const metrics = runMetrics(recorded, { contextLength: config.contextLength });
727
760
  let outcome = RunOutcome.Answered;
728
761
  if (ended) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.13.0",
3
+ "version": "4.15.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",