@cubicecho/agent-core 4.14.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
@@ -887,9 +887,29 @@ turn is remembered, awaits it once the answer is out.
887
887
  The loop gathers before its first request, beside its own preselection when `preselect` asked for
888
888
  one, and nothing else of the host's can run beside those. A host with pre-turn work to overlap with
889
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.
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.
893
913
 
894
914
  A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
895
915
  readable history — gets each message as the loop appends it, and the run as it stood when the loop
@@ -46,16 +46,38 @@ export interface AgentLoopRequest {
46
46
  * The loop gathers before its first request, beside its own preselection where it was asked for
47
47
  * one, and nothing else runs beside the two. A host with pre-turn work of its own to overlap with
48
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
+ * `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.
50
52
  */
51
53
  export interface AgentLoopHooks {
52
54
  run: HookRunner;
53
55
  /** What the hooks are told. `reply` and `turn` are filled in for `afterTurn`. */
54
56
  context: HookContext;
55
57
  /**
56
- * 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.
57
77
  *
58
78
  * @defaultValue `[HookEvent.BeforeTurn]`
79
+ * @deprecated List them in `fire` instead, with `afterTurn` and `beforeCompact` where the loop
80
+ * should fire those too.
59
81
  */
60
82
  events?: readonly HookEvent[];
61
83
  /**
@@ -70,7 +92,7 @@ export interface AgentLoopHooks {
70
92
  * @defaultValue The preface `configureHooks` holds.
71
93
  */
72
94
  preface?: string;
73
- /** Hears each note, from before the request and from `afterTurn`. */
95
+ /** Hears each note, from before the request, from `beforeCompact` and from `afterTurn`. */
74
96
  onNote?: OnNote;
75
97
  /**
76
98
  * What the run's first message is numbered as in the uuids hooks are told — the host's stored
@@ -326,7 +348,8 @@ export interface AgentLoopOptions {
326
348
  */
327
349
  onToolResult?: (result: ToolCallResult) => void;
328
350
  /**
329
- * 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.
330
353
  *
331
354
  * @remarks
332
355
  * Only this question is touched — the last user message of `messages` — and the context is on
@@ -466,12 +489,13 @@ export interface AgentLoopResult {
466
489
  * preselection picked it. Empty in eager mode, and where nothing was picked.
467
490
  */
468
491
  preselected: string[];
469
- /** The hooks' notes from before the first request. */
492
+ /** The hooks' notes from before the first request. Empty where `fire` lists no injecting event. */
470
493
  notes: HookNote[];
471
494
  /**
472
495
  * The `<context>` blocks the hooks added to this question, as `withContext` takes them. Empty
473
- * when no hook added any, and without `hooks`. Not in `messages`, so a host that wants the next
474
- * 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`.
475
499
  */
476
500
  context: string;
477
501
  /**
@@ -486,8 +510,9 @@ export interface AgentLoopResult {
486
510
  * The `afterTurn` hooks' notes, once they have run — failures only, as `notify` returns them.
487
511
  * The loop does not wait for it, so the answer is never held for a hook; a host that stores the
488
512
  * notes with the turn, or must not exit before the turn is remembered, awaits it. Each note
489
- * reaches `onNote` as well. Already resolved and empty without `hooks`, and rejects only if
490
- * `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.
491
516
  */
492
517
  afterTurn: Promise<HookNote[]>;
493
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';
@@ -281,6 +281,7 @@ function ownSummariser(options, notice) {
281
281
  */
282
282
  async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice }) {
283
283
  const { config, hooks } = options;
284
+ const told = hooks && firing(hooks).beforeCompact ? hooks : undefined;
284
285
  const compactor = config.tasks?.compaction;
285
286
  const plan = planCompaction(transcript, {
286
287
  ...(typeof options.compact === 'object' ? options.compact : {}),
@@ -293,13 +294,37 @@ async function foldBeforeStep(transcript, reported, summarise, { options, charsP
293
294
  return transcript;
294
295
  }
295
296
  const folded = await compactTranscript(transcript, plan, summarise, {
296
- ...(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 } } : {}),
297
298
  });
298
299
  if (folded !== transcript) {
299
300
  notice(`compacted ${plan.toSummarise.length} messages into a summary`);
300
301
  }
301
302
  return folded;
302
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
+ }
303
328
  /**
304
329
  * Tells the `afterTurn` hooks what was answered, with the turn it closed where the question is
305
330
  * still in the transcript.
@@ -499,8 +524,9 @@ async function runSteps(options, standing, pin) {
499
524
  // they cost the first request two round trips where one will do. A failing runner is a note, but
500
525
  // a host's `onNote` that throws rejects the gather, perhaps while the preselection is still out:
501
526
  // handled at once so that is no unhandled rejection, and still thrown below, where it always was.
527
+ const fired = hooks && firing(hooks);
502
528
  const gathering = hooks
503
- ? gather(hooks.run, hooks.events ?? [HookEvent.BeforeTurn], hooks.context, {
529
+ ? gather(hooks.run, fired?.before ?? [], hooks.context, {
504
530
  signal,
505
531
  onNote: hooks.onNote,
506
532
  maxTokens: hooks.maxTokens,
@@ -729,7 +755,7 @@ async function runSteps(options, standing, pin) {
729
755
  if (isAnswer || ended) {
730
756
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
731
757
  // Handed back instead, for a host that wants its notes.
732
- 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([]);
733
759
  const metrics = runMetrics(recorded, { contextLength: config.contextLength });
734
760
  let outcome = RunOutcome.Answered;
735
761
  if (ended) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.14.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",