@cubicecho/agent-core 4.14.0 → 4.16.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
@@ -41,7 +41,7 @@ only, Node >=22.
41
41
  | `run-calls` | The tools between two turns: a step's calls read, run and written into the transcript, with every call answered whatever happens. `preview` cuts a long argument or result for a watcher. |
42
42
  | `preselect` | Choosing a run's tools before it starts: `preselect` asks a small model, `preselectByKeywords` scores the request's own words against the tool names without one, and `preselectSystem`, `preselectInput` and `preselection` are the prompt and the reading of the reply for a host that makes the call itself. |
43
43
  | `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. Plus the shapes a call is handed round in: `ToolCallRequest`, `ToolCallResult`, `ToolCallOutcome`. |
44
- | `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
44
+ | `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planPrune` moves a boundary for them that keeps the prompt cache, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
45
45
  | `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
46
46
  | `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
47
47
  | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. `defineSpecColumns` maps a stored row onto a layer and back, at `@cubicecho/agent-core/spec/columns`. |
@@ -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
@@ -1047,6 +1067,38 @@ way to save tokens.
1047
1067
  `pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
1048
1068
  what it returns, as above.
1049
1069
 
1070
+ ### A prune boundary that keeps the cache
1071
+
1072
+ `pruneToolResults` keeps a sliding window: applied on every step, each new result pushes an older
1073
+ one out and rewrites a message in the middle of the history, so the cache misses from there once
1074
+ per tool call. `planPrune` plans a boundary instead. The host stores its `through`, sends
1075
+ `pruneThrough(messages, through)` on every request, and between moves each request is the one
1076
+ before it with only its tail added.
1077
+
1078
+ ```ts
1079
+ const plan = planPrune(messages, config.contextLength ?? 0, { through: stored, used: lastPromptTokens });
1080
+ if (plan) {
1081
+ stored = plan.through;
1082
+ notice(pruneNotice(plan)); // cleared 4 old tool results (~9,120 tokens) as the window filled
1083
+ }
1084
+ const sent = pruneThrough(messages, stored);
1085
+ ```
1086
+
1087
+ A plan never moves the boundary backwards and never moves it for nothing. It moves to the earliest
1088
+ of the latest `keepLast` (5) results once the window is past `at` (`COMPACT_AT`, the fold's own
1089
+ line) — by the larger of `used` and the estimate, since a report does not count the results that
1090
+ came after it — and only when what it clears is at least `minShare` (`PRUNE_SHARE`, a tenth) of
1091
+ the window. `compacted: true`, beside a fold that has rewritten the head anyway, skips both gates:
1092
+ the cache is already lost, so the move is free. `from` leaves out results a fold has already taken
1093
+ out of the request. `at: 0` with a larger `minShare` is a pure hysteresis on what is won back.
1094
+
1095
+ `runAgentLoop({ prune: true })` does this itself: after `beforeStep`, ahead of `compact`, which
1096
+ then plans from what is left — so a prune that brings the run back under the line spares it the
1097
+ summary — and again with `compacted` after a fold. Each move is a `notice` with the counts. Its
1098
+ stubs stay in the loop's transcript and in the result's `messages`, as a fold's summary does; a
1099
+ host that keeps the transcript whole calls `planPrune` in its own `beforeStep` and stores the
1100
+ boundary. An object moves `at`, `minShare`, `keepLast` and `maxChars`.
1101
+
1050
1102
  ### What the messages cost, measured
1051
1103
 
1052
1104
  `planCompaction` decides where to cut by estimating each message, and a calibrated divisor fixes
@@ -63,6 +63,139 @@ export interface PruneOptions {
63
63
  * see the module comment on when to run it.
64
64
  */
65
65
  export declare function pruneToolResults(messages: Message[], { keepLast, maxChars }?: PruneOptions): Message[];
66
+ /**
67
+ * The share of the window a prune has to win back on its own before it is worth the prompt cache
68
+ * it costs. Below it the request after a prune is barely smaller than the one before, and every
69
+ * token of it is processed again anyway.
70
+ */
71
+ export declare const PRUNE_SHARE = 0.1;
72
+ /** What `planPrune` takes beside the transcript and the window. */
73
+ export interface PrunePlanOptions extends PruneOptions {
74
+ /**
75
+ * The boundary in force: results before it are already sent as stubs. A plan only ever moves it
76
+ * forward.
77
+ *
78
+ * @defaultValue `0`
79
+ */
80
+ through?: number;
81
+ /**
82
+ * The share of the window a move has to clear, in tokens, to be made on its own. Zero makes any
83
+ * move that clears something.
84
+ *
85
+ * @defaultValue `PRUNE_SHARE`
86
+ */
87
+ minShare?: number;
88
+ /**
89
+ * The share of the window in use before a move is considered at all. Zero considers one on every
90
+ * step, and the hysteresis is `minShare`'s alone.
91
+ *
92
+ * @defaultValue `COMPACT_AT`, so a prune is paid for when a fold would be
93
+ */
94
+ at?: number;
95
+ /**
96
+ * What the transcript costs now — the last request's reported prompt tokens. The fill weighed
97
+ * against `at` is the larger of it and the estimate, since a report does not count the results
98
+ * that arrived after it, which are what fills a window.
99
+ */
100
+ used?: number;
101
+ /**
102
+ * The first message still sent. Results before it were folded into a summary and are no saving
103
+ * of this one, so they are not counted.
104
+ *
105
+ * @defaultValue `0`
106
+ */
107
+ from?: number;
108
+ /**
109
+ * The head of the request was rewritten on this step anyway — a fold was just stored — so the
110
+ * cache is lost whatever a move does: `at` and `minShare` are not asked, and a move that clears
111
+ * anything is made, with a window known or not.
112
+ *
113
+ * @defaultValue `false`
114
+ */
115
+ compacted?: boolean;
116
+ /**
117
+ * One message's tokens.
118
+ *
119
+ * @defaultValue `messageTokens` divided by `charsPerToken`
120
+ */
121
+ estimate?: (message: Message) => number;
122
+ /**
123
+ * The divisor the default `estimate` uses. Ignored beside an `estimate` of the caller's own.
124
+ *
125
+ * @defaultValue `4`
126
+ */
127
+ charsPerToken?: number;
128
+ }
129
+ /** Where `planPrune` puts the boundary, and what moving it there clears. */
130
+ export interface PrunePlan {
131
+ /**
132
+ * The new boundary: tool results before it are sent as stubs (`pruneThrough`), results at or
133
+ * after it whole. Always greater than the one in force. An index into the transcript the plan
134
+ * was made for, which the host stores.
135
+ */
136
+ through: number;
137
+ /** How many results the move stubs that were being sent whole. */
138
+ cleared: number;
139
+ /** Their estimated tokens, less the stubs', rounded. */
140
+ tokens: number;
141
+ }
142
+ /**
143
+ * The transcript with every tool result before `through` replaced by a stub.
144
+ *
145
+ * @param messages - The transcript. Not written to.
146
+ * @param through - The boundary, as a `PrunePlan` has it. Clamped to the transcript, so one left
147
+ * past its end cannot reach messages not yet written.
148
+ * @param [options] - `maxChars`, as `pruneToolResults` reads it.
149
+ * @returns `messages` itself when nothing was cleared, otherwise a new array of the same length in
150
+ * which each cleared result is a new message and every other one the same object.
151
+ *
152
+ * @remarks
153
+ * `pruneToolResults` over the head, keeping none of it — so a result that is short or carries
154
+ * definitions stays whole here as it does there. A function of the messages before `through` and
155
+ * nothing else: while the boundary stays put, what this sends for them does not change however
156
+ * many results arrive after it, which is what keeps the prompt cache.
157
+ */
158
+ export declare function pruneThrough(messages: Message[], through: number, { maxChars }?: Pick<PruneOptions, 'maxChars'>): Message[];
159
+ /**
160
+ * Where to move the prune boundary, or `undefined` when it should stay where it is.
161
+ *
162
+ * @param messages - The transcript as the host keeps it — whole, or with earlier stubs in it.
163
+ * @param window - The model's window, in tokens. Zero or less moves nothing on its own.
164
+ * @param [options] - The boundary in force, the gate, what to keep, and how to weigh a message.
165
+ * See `PrunePlanOptions`.
166
+ * @returns The plan, or `undefined`. `messages` is not written to.
167
+ *
168
+ * @remarks
169
+ * `pruneToolResults` keeps a sliding window of results: applied on every step, each new result
170
+ * pushes an older one out, rewrites a message in the middle of the history and loses the prompt
171
+ * cache from there on, once per tool call. A plan is a boundary instead. The host stores
172
+ * `through` and sends `pruneThrough(messages, through)` on every request, and between moves a
173
+ * request is the one before it with only its tail added.
174
+ *
175
+ * The rule, in the order it is asked:
176
+ *
177
+ * - **Never backwards, and never for nothing.** The target is the earliest of the latest
178
+ * `keepLast` results. If that is not past `through`, or moving there would clear nothing still
179
+ * being sent, there is no plan.
180
+ * - **With a fold, for free.** Given `compacted`, the move is made.
181
+ * - **Otherwise once the window is filling**, past `at` of it, and only when what it clears is at
182
+ * least `minShare` of it. A run sitting just over the line would otherwise slide the boundary
183
+ * forward and lose the cache on every step for a few hundred tokens each time.
184
+ *
185
+ * What is weighed is what the move would *clear*, by `estimate`: a short result and a proxied
186
+ * load's definitions stay whole wherever the boundary is, a stub is not cleared twice, and a
187
+ * result before `from` is not being sent.
188
+ */
189
+ export declare function planPrune(messages: Message[], window: number, { through, minShare, at, used, from, compacted, keepLast, maxChars, charsPerToken, estimate, }?: PrunePlanOptions): PrunePlan | undefined;
190
+ /**
191
+ * What a host says when a plan is applied, so every host says it the same way.
192
+ *
193
+ * @param plan - The plan that was applied.
194
+ * @param [compacted] - Whether it was made alongside a fold rather than because the window filled.
195
+ * @returns One line with the counts: `cleared 3 old tool results (~12,345 tokens) as the window
196
+ * filled`.
197
+ */
198
+ export declare function pruneNotice({ cleared, tokens }: PrunePlan, compacted?: boolean): string;
66
199
  /** What `planCompaction` takes. */
67
200
  export interface CompactionOptions {
68
201
  /** The model's window, in tokens. Zero or less never compacts. */
@@ -14,6 +14,8 @@ const SUMMARY_TOKENS = 1024;
14
14
  const SUMMARY_SLICE = 4000;
15
15
  /** The fewest messages worth folding: a summary of one costs a round trip and saves nothing. */
16
16
  const FEWEST_FOLDED = 2;
17
+ /** How many of the latest tool results a prune leaves whole when the caller does not say. */
18
+ const KEEP_LAST = 5;
17
19
  /** The locale a cleared result's length is written in, so the stub reads the same on every host. */
18
20
  const COUNT_LOCALE = 'en-US';
19
21
  /**
@@ -105,7 +107,7 @@ const systemHead = (messages) => {
105
107
  * few. Returns the same array when there was nothing to clear. Rewrites the prefix;
106
108
  * see the module comment on when to run it.
107
109
  */
108
- export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}) {
110
+ export function pruneToolResults(messages, { keepLast = KEEP_LAST, maxChars = 256 } = {}) {
109
111
  let kept = 0;
110
112
  let out;
111
113
  for (let at = messages.length - 1; at >= 0; at--) {
@@ -133,6 +135,132 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
133
135
  }
134
136
  return out ?? messages;
135
137
  }
138
+ /**
139
+ * The share of the window a prune has to win back on its own before it is worth the prompt cache
140
+ * it costs. Below it the request after a prune is barely smaller than the one before, and every
141
+ * token of it is processed again anyway.
142
+ */
143
+ export const PRUNE_SHARE = 0.1;
144
+ /**
145
+ * The transcript with every tool result before `through` replaced by a stub.
146
+ *
147
+ * @param messages - The transcript. Not written to.
148
+ * @param through - The boundary, as a `PrunePlan` has it. Clamped to the transcript, so one left
149
+ * past its end cannot reach messages not yet written.
150
+ * @param [options] - `maxChars`, as `pruneToolResults` reads it.
151
+ * @returns `messages` itself when nothing was cleared, otherwise a new array of the same length in
152
+ * which each cleared result is a new message and every other one the same object.
153
+ *
154
+ * @remarks
155
+ * `pruneToolResults` over the head, keeping none of it — so a result that is short or carries
156
+ * definitions stays whole here as it does there. A function of the messages before `through` and
157
+ * nothing else: while the boundary stays put, what this sends for them does not change however
158
+ * many results arrive after it, which is what keeps the prompt cache.
159
+ */
160
+ export function pruneThrough(messages, through, { maxChars } = {}) {
161
+ const end = clamp(through, messages.length);
162
+ if (end <= 0) {
163
+ return messages;
164
+ }
165
+ const head = messages.slice(0, end);
166
+ const stubbed = pruneToolResults(head, { keepLast: 0, ...(maxChars === undefined ? {} : { maxChars }) });
167
+ return stubbed === head ? messages : [...stubbed, ...messages.slice(end)];
168
+ }
169
+ /**
170
+ * Where the latest `keepLast` tool results begin, which is where a move puts the boundary.
171
+ *
172
+ * @param messages - The transcript.
173
+ * @param keepLast - How many of the latest results stay whole.
174
+ * @returns The index of the earliest of them; the length when `keepLast` is zero; and zero when
175
+ * there are not that many results, which is nowhere.
176
+ */
177
+ const keepBoundary = (messages, keepLast) => {
178
+ if (keepLast <= 0) {
179
+ return messages.length;
180
+ }
181
+ let kept = 0;
182
+ for (let at = messages.length - 1; at >= 0; at--) {
183
+ if (messages[at].role === Role.Tool && ++kept === keepLast) {
184
+ return at;
185
+ }
186
+ }
187
+ return 0;
188
+ };
189
+ /**
190
+ * Where to move the prune boundary, or `undefined` when it should stay where it is.
191
+ *
192
+ * @param messages - The transcript as the host keeps it — whole, or with earlier stubs in it.
193
+ * @param window - The model's window, in tokens. Zero or less moves nothing on its own.
194
+ * @param [options] - The boundary in force, the gate, what to keep, and how to weigh a message.
195
+ * See `PrunePlanOptions`.
196
+ * @returns The plan, or `undefined`. `messages` is not written to.
197
+ *
198
+ * @remarks
199
+ * `pruneToolResults` keeps a sliding window of results: applied on every step, each new result
200
+ * pushes an older one out, rewrites a message in the middle of the history and loses the prompt
201
+ * cache from there on, once per tool call. A plan is a boundary instead. The host stores
202
+ * `through` and sends `pruneThrough(messages, through)` on every request, and between moves a
203
+ * request is the one before it with only its tail added.
204
+ *
205
+ * The rule, in the order it is asked:
206
+ *
207
+ * - **Never backwards, and never for nothing.** The target is the earliest of the latest
208
+ * `keepLast` results. If that is not past `through`, or moving there would clear nothing still
209
+ * being sent, there is no plan.
210
+ * - **With a fold, for free.** Given `compacted`, the move is made.
211
+ * - **Otherwise once the window is filling**, past `at` of it, and only when what it clears is at
212
+ * least `minShare` of it. A run sitting just over the line would otherwise slide the boundary
213
+ * forward and lose the cache on every step for a few hundred tokens each time.
214
+ *
215
+ * What is weighed is what the move would *clear*, by `estimate`: a short result and a proxied
216
+ * load's definitions stay whole wherever the boundary is, a stub is not cleared twice, and a
217
+ * result before `from` is not being sent.
218
+ */
219
+ export function planPrune(messages, window, { through = 0, minShare = PRUNE_SHARE, at = COMPACT_AT, used, from = 0, compacted = false, keepLast = KEEP_LAST, maxChars, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), } = {}) {
220
+ const hasWindow = window > 0;
221
+ if (hasWindow === false && compacted === false) {
222
+ return undefined;
223
+ }
224
+ if (compacted === false) {
225
+ const estimated = messages.reduce((total, message) => total + estimate(message), 0);
226
+ const isFilling = Math.max(used ?? 0, estimated) >= window * at;
227
+ if (isFilling === false) {
228
+ return undefined;
229
+ }
230
+ }
231
+ const current = clamp(through, messages.length);
232
+ const target = keepBoundary(messages, keepLast);
233
+ if (target <= current) {
234
+ return undefined;
235
+ }
236
+ const sent = pruneThrough(messages, target, { maxChars });
237
+ let cleared = 0;
238
+ let tokens = 0;
239
+ for (let index = Math.max(current, from); index < target; index++) {
240
+ if (sent[index] !== messages[index]) {
241
+ cleared++;
242
+ tokens += estimate(messages[index]) - estimate(sent[index]);
243
+ }
244
+ }
245
+ if (cleared === 0) {
246
+ return undefined;
247
+ }
248
+ const isWorthIt = compacted || tokens >= window * minShare;
249
+ return isWorthIt ? { through: target, cleared, tokens: Math.round(tokens) } : undefined;
250
+ }
251
+ /**
252
+ * What a host says when a plan is applied, so every host says it the same way.
253
+ *
254
+ * @param plan - The plan that was applied.
255
+ * @param [compacted] - Whether it was made alongside a fold rather than because the window filled.
256
+ * @returns One line with the counts: `cleared 3 old tool results (~12,345 tokens) as the window
257
+ * filled`.
258
+ */
259
+ export function pruneNotice({ cleared, tokens }, compacted = false) {
260
+ const results = cleared === 1 ? 'result' : 'results';
261
+ const why = compacted ? 'alongside a fold' : 'as the window filled';
262
+ return `cleared ${cleared} old tool ${results} (~${tokens.toLocaleString(COUNT_LOCALE)} tokens) ${why}`;
263
+ }
136
264
  /**
137
265
  * An index held between the start of the transcript and `most`, for a caller's `from` that is
138
266
  * neither.
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- 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, } from './context/compaction.ts';
12
+ export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, PRUNE_SHARE, type PruneOptions, type PrunePlan, type PrunePlanOptions, planCompaction, planPrune, pruneNotice, pruneThrough, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.ts';
13
13
  export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from './context/ledger.ts';
14
14
  export { type AgentConfig, type Endpoint, type ModelParams, normalizeBaseUrl, type RetryPolicy, ToolDiscovery, type ToolPolicy, } from './core/config.ts';
15
15
  export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.ts';
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.js';
12
+ export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, PRUNE_SHARE, planCompaction, planPrune, pruneNotice, pruneThrough, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.js';
13
13
  export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from './context/ledger.js';
14
14
  export { normalizeBaseUrl, ToolDiscovery, } from './core/config.js';
15
15
  export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.js';
@@ -1,5 +1,5 @@
1
1
  import type OpenAI from 'openai';
2
- import { type CompactionOptions } from '../context/compaction.ts';
2
+ import { type CompactionOptions, type PrunePlanOptions } from '../context/compaction.ts';
3
3
  import { type TokenLedger } from '../context/ledger.ts';
4
4
  import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from '../core/config.ts';
5
5
  import { HookEvent } from '../hooks/hook-events.ts';
@@ -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
@@ -244,13 +266,30 @@ export interface AgentLoopOptions {
244
266
  * more: the plan is made from the last request's reported prompt, or an estimate before the
245
267
  * first step and after a `beforeStep` rewrite; `hooks`, when given, are told `beforeCompact`
246
268
  * beside the summary; and a fold is said as a `notice` event. A summary that fails is a notice
247
- * too and folds nothing. Tool results are not pruned — `beforeStep` is where a host does that.
269
+ * too and folds nothing. Tool results are not pruned by this — `prune` is.
248
270
  *
249
271
  * The result's `messages` then open with the summary in place of what was folded, so a host
250
272
  * that keeps its transcript append-only wants `runCompaction` and a stored fold instead. `true`
251
273
  * plans by `planCompaction`'s defaults; an object moves its thresholds.
252
274
  */
253
275
  compact?: boolean | Pick<CompactionOptions, 'compactAt' | 'keepRatio' | 'target'>;
276
+ /**
277
+ * Has the loop stub old tool results itself before a step, at a boundary `planPrune` moves, so
278
+ * the prompt cache is lost once per move rather than once per tool call. Off by default, and
279
+ * nothing moves without a `config.contextLength` — except alongside a fold.
280
+ *
281
+ * @remarks
282
+ * It runs after `beforeStep`, on what that returned, and before `compact`, which then plans from
283
+ * what is left: a prune that brings the run back under the line spares it the summary's round
284
+ * trip. Where a fold is made anyway, the boundary is moved again after it with `compacted`, since
285
+ * the cache is already lost. Each move is said as a `notice` event, with `pruneNotice`'s counts.
286
+ *
287
+ * The stubs are in the result's `messages`, as a fold is, so a host that keeps its transcript
288
+ * whole calls `planPrune` and `pruneThrough` in its own `beforeStep` and stores the boundary
289
+ * instead. `true` plans by `planPrune`'s defaults — past `COMPACT_AT` of the window, and only for
290
+ * at least `PRUNE_SHARE` of it; an object moves the gate and what is kept.
291
+ */
292
+ prune?: boolean | Pick<PrunePlanOptions, 'at' | 'minShare' | 'keepLast' | 'maxChars'>;
254
293
  /**
255
294
  * Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
256
295
  * mode, where a tool is loaded only while its definition is in the history.
@@ -326,7 +365,8 @@ export interface AgentLoopOptions {
326
365
  */
327
366
  onToolResult?: (result: ToolCallResult) => void;
328
367
  /**
329
- * Hooks gathered onto the question before the first request, and told the reply after.
368
+ * Hooks gathered onto the question before the first request, and told the reply after — or
369
+ * whichever of those `fire` names.
330
370
  *
331
371
  * @remarks
332
372
  * Only this question is touched — the last user message of `messages` — and the context is on
@@ -466,12 +506,13 @@ export interface AgentLoopResult {
466
506
  * preselection picked it. Empty in eager mode, and where nothing was picked.
467
507
  */
468
508
  preselected: string[];
469
- /** The hooks' notes from before the first request. */
509
+ /** The hooks' notes from before the first request. Empty where `fire` lists no injecting event. */
470
510
  notes: HookNote[];
471
511
  /**
472
512
  * 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`.
513
+ * when no hook added any, without `hooks`, and where `fire` lists no injecting event. Not in
514
+ * `messages`, so a host that wants the next turn's request to begin with this one's stores it
515
+ * beside the question; see `hooks`.
475
516
  */
476
517
  context: string;
477
518
  /**
@@ -486,8 +527,9 @@ export interface AgentLoopResult {
486
527
  * The `afterTurn` hooks' notes, once they have run — failures only, as `notify` returns them.
487
528
  * The loop does not wait for it, so the answer is never held for a hook; a host that stores the
488
529
  * 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.
530
+ * reaches `onNote` as well. Already resolved and empty without `hooks`, and where `fire` leaves
531
+ * `afterTurn` out — a host that tells it itself has the notes from its own `notify`. Rejects only
532
+ * if `onNote` throws.
491
533
  */
492
534
  afterTurn: Promise<HookNote[]>;
493
535
  /**
@@ -1,4 +1,4 @@
1
- import { compactTranscript, planCompaction, summariser } from '../context/compaction.js';
1
+ import { compactTranscript, planCompaction, planPrune, pruneNotice, pruneThrough, summariser, } from '../context/compaction.js';
2
2
  import { breakReason, rebaseLedger, recordRequest, } from '../context/ledger.js';
3
3
  import { ToolDiscovery } from '../core/config.js';
4
4
  import { counted } from '../core/guards.js';
@@ -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';
@@ -265,6 +265,36 @@ function ownSummariser(options, notice) {
265
265
  // A summary that fails folds nothing, and the step goes out as it was.
266
266
  return async (text) => (await tryAsk('compaction', () => write(text), { onNotice: notice, signal })) ?? '';
267
267
  }
268
+ /**
269
+ * A step's transcript with the loop's prune boundary moved, where a move was due.
270
+ *
271
+ * @param transcript - The step's messages. Not written to.
272
+ * @param through - The boundary in force, which only ever moves forward.
273
+ * @param context - The run's options, read for `prune`'s gate and the window; what the last
274
+ * request reported, where it still describes `transcript`; the characters per token an estimate is
275
+ * made at; whether a fold was just made; and `notice`, told of a move.
276
+ * @returns `transcript` itself and the boundary unmoved where nothing was due, otherwise the
277
+ * stubbed array, the new boundary and what it won back.
278
+ */
279
+ function pruneBeforeStep(transcript, through, { options, used, charsPerToken, compacted = false, notice, }) {
280
+ const gate = typeof options.prune === 'object' ? options.prune : {};
281
+ const plan = planPrune(transcript, options.config.contextLength ?? 0, {
282
+ ...gate,
283
+ through,
284
+ ...(used === undefined ? {} : { used }),
285
+ charsPerToken,
286
+ compacted,
287
+ });
288
+ if (!plan) {
289
+ return { messages: transcript, through, tokens: 0 };
290
+ }
291
+ notice(pruneNotice(plan, compacted));
292
+ return {
293
+ messages: pruneThrough(transcript, plan.through, { maxChars: gate.maxChars }),
294
+ through: plan.through,
295
+ tokens: plan.tokens,
296
+ };
297
+ }
268
298
  /**
269
299
  * A step's transcript after the loop's own compaction: folded where a fold was due and its summary
270
300
  * came back, and otherwise the array it was given.
@@ -281,6 +311,7 @@ function ownSummariser(options, notice) {
281
311
  */
282
312
  async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice }) {
283
313
  const { config, hooks } = options;
314
+ const told = hooks && firing(hooks).beforeCompact ? hooks : undefined;
284
315
  const compactor = config.tasks?.compaction;
285
316
  const plan = planCompaction(transcript, {
286
317
  ...(typeof options.compact === 'object' ? options.compact : {}),
@@ -293,13 +324,37 @@ async function foldBeforeStep(transcript, reported, summarise, { options, charsP
293
324
  return transcript;
294
325
  }
295
326
  const folded = await compactTranscript(transcript, plan, summarise, {
296
- ...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote, offset: hooks.offset } } : {}),
327
+ ...(told ? { hooks: { run: told.run, context: told.context, onNote: told.onNote, offset: told.offset } } : {}),
297
328
  });
298
329
  if (folded !== transcript) {
299
330
  notice(`compacted ${plan.toSummarise.length} messages into a summary`);
300
331
  }
301
332
  return folded;
302
333
  }
334
+ /**
335
+ * Which events the loop fires for these hooks, read from `fire`, or from `events` without it.
336
+ *
337
+ * @param hooks - The run's hooks.
338
+ * @returns The events to gather before the first request, and whether `afterTurn` and
339
+ * `beforeCompact` are the loop's to fire.
340
+ *
341
+ * @remarks
342
+ * Without `fire`, `events` goes to `gather` as it was given, unfiltered, so a host that had put
343
+ * something odd in it sees what it saw before. With `fire`, only the injecting events are gathered:
344
+ * a list that names every event is the whole of the loop's firing, and `afterTurn` gathered before
345
+ * the request would be the hooks told a reply that does not exist yet.
346
+ */
347
+ function firing(hooks) {
348
+ if (!hooks.fire) {
349
+ return { before: hooks.events ?? [HookEvent.BeforeTurn], afterTurn: true, beforeCompact: true };
350
+ }
351
+ const fire = new Set(hooks.fire);
352
+ return {
353
+ before: [...fire].filter((event) => INJECT_EVENTS.has(event)),
354
+ afterTurn: fire.has(HookEvent.AfterTurn),
355
+ beforeCompact: fire.has(HookEvent.BeforeCompact),
356
+ };
357
+ }
303
358
  /**
304
359
  * Tells the `afterTurn` hooks what was answered, with the turn it closed where the question is
305
360
  * still in the transcript.
@@ -499,8 +554,9 @@ async function runSteps(options, standing, pin) {
499
554
  // they cost the first request two round trips where one will do. A failing runner is a note, but
500
555
  // a host's `onNote` that throws rejects the gather, perhaps while the preselection is still out:
501
556
  // handled at once so that is no unhandled rejection, and still thrown below, where it always was.
557
+ const fired = hooks && firing(hooks);
502
558
  const gathering = hooks
503
- ? gather(hooks.run, hooks.events ?? [HookEvent.BeforeTurn], hooks.context, {
559
+ ? gather(hooks.run, fired?.before ?? [], hooks.context, {
504
560
  signal,
505
561
  onNote: hooks.onNote,
506
562
  maxTokens: hooks.maxTokens,
@@ -549,6 +605,9 @@ async function runSteps(options, standing, pin) {
549
605
  const preface = hooks?.preface ?? configureHooks().preface;
550
606
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
551
607
  let previous;
608
+ // The loop's prune boundary. Its stubs stay in `messages`, so between moves each request only
609
+ // extends the last; a rewrite or a fold renumbers the transcript, and it starts again from zero.
610
+ let pruneBoundary = 0;
552
611
  let ledger = options.ledger ?? [];
553
612
  // The request the ledger's last entry came from, which is what the next one is measured against.
554
613
  // Nothing before this run's first: an earlier run's head is not something the loop can see.
@@ -596,14 +655,28 @@ async function runSteps(options, standing, pin) {
596
655
  ledger,
597
656
  });
598
657
  const given = rewritten ?? messages;
599
- // The last request's prompt describes this transcript only while nothing has rewritten it.
600
- const next = summarise
601
- ? await foldBeforeStep(given, given === messages ? reported : {}, summarise, {
602
- options,
603
- charsPerToken: charsPerTokenFor(supports, config.model),
604
- notice,
605
- })
606
- : given;
658
+ const isOwn = given === messages;
659
+ if (isOwn === false) {
660
+ pruneBoundary = 0;
661
+ }
662
+ // Read again after the turn: a reported prompt calibrates it.
663
+ const planRatio = charsPerTokenFor(supports, config.model);
664
+ // The last request's prompt describes this transcript only while nothing has rewritten it,
665
+ // and less what a prune has just won back of it.
666
+ const ownUsed = isOwn ? reported.used : undefined;
667
+ const trimmed = options.prune
668
+ ? pruneBeforeStep(given, pruneBoundary, { options, used: ownUsed, charsPerToken: planRatio, notice })
669
+ : { messages: given, through: pruneBoundary, tokens: 0 };
670
+ pruneBoundary = trimmed.through;
671
+ const foldUsed = ownUsed === undefined ? {} : { used: Math.max(0, ownUsed - trimmed.tokens) };
672
+ let next = summarise
673
+ ? await foldBeforeStep(trimmed.messages, foldUsed, summarise, { options, charsPerToken: planRatio, notice })
674
+ : trimmed.messages;
675
+ if (options.prune && next !== trimmed.messages) {
676
+ const after = pruneBeforeStep(next, 0, { options, charsPerToken: planRatio, compacted: true, notice });
677
+ next = after.messages;
678
+ pruneBoundary = after.through;
679
+ }
607
680
  // A rewrite may have folded a definition away, and a load answered "already loaded" would
608
681
  // then point at nothing. Forgetting costs a definition sent twice at worst.
609
682
  if (proxied && next !== messages) {
@@ -729,7 +802,7 @@ async function runSteps(options, standing, pin) {
729
802
  if (isAnswer || ended) {
730
803
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
731
804
  // Handed back instead, for a host that wants its notes.
732
- const afterTurn = hooks ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
805
+ const afterTurn = hooks && fired?.afterTurn ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
733
806
  const metrics = runMetrics(recorded, { contextLength: config.contextLength });
734
807
  let outcome = RunOutcome.Answered;
735
808
  if (ended) {
package/llms.txt CHANGED
@@ -21,8 +21,14 @@ Keeping a long run inside its window: stale tool results cleared, and the oldest
21
21
  - `CompactionRunOptions` (type) — What `runCompaction` and `compactTranscript` take beside the plan.
22
22
  - `compactTranscript` — The transcript with the plan's stretch replaced by one system message holding its summary.
23
23
  - `KEEP_RATIO` — The fraction of the window the kept tail may fill, leaving room for the run to grow again.
24
+ - `PRUNE_SHARE` — The share of the window a prune has to win back on its own before it is worth the prompt cache it costs.
24
25
  - `PruneOptions` (type) — What `pruneToolResults` takes.
26
+ - `PrunePlan` (type) — Where `planPrune` puts the boundary, and what moving it there clears.
27
+ - `PrunePlanOptions` (type) — What `planPrune` takes beside the transcript and the window.
25
28
  - `planCompaction` — Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
29
+ - `planPrune` — Where to move the prune boundary, or `undefined` when it should stay where it is.
30
+ - `pruneNotice` — What a host says when a plan is applied, so every host says it the same way.
31
+ - `pruneThrough` — The transcript with every tool result before `through` replaced by a stub.
26
32
  - `pruneToolResults` — The transcript with every tool result but the latest few replaced by a one-line stub.
27
33
  - `requestIndex` — Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted everything after it.
28
34
  - `runCompaction` — The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.14.0",
3
+ "version": "4.16.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",