@cubicecho/agent-core 2.11.0 → 2.13.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.
@@ -0,0 +1,159 @@
1
+ import OpenAI from "openai";
2
+ import { modelCapabilitiesFor } from "./capabilities.js";
3
+ import { errorMessage } from "./errors.js";
4
+ import { ContextOverflow } from "./retry.js";
5
+ import { runTurn } from "./run-turn.js";
6
+ /**
7
+ * Whether a turn is one a continuation can finish: cut off at the ceiling, with an answer begun
8
+ * and no tool call in it.
9
+ *
10
+ * An answer not begun is a turn cut off in its scratchpad, and prefilling a half-closed fence is
11
+ * the template's business rather than something this can do the same way everywhere — llama.cpp
12
+ * refuses a prefill outright on a template with thinking enabled. A tool call is excluded because
13
+ * its arguments are what was cut, and `parseToolArguments` already reports that truncation.
14
+ *
15
+ * @param turn The turn as it came back.
16
+ */
17
+ export const isContinuable = (turn) => turn.finishReason === "length" && turn.content.trim() !== "" && turn.toolCalls.length === 0;
18
+ /** How much of the answer's opening a reply has to repeat to have started over. */
19
+ const RESTART_PROBE = 40;
20
+ /** The shortest opening worth testing for a restart; shorter ones are repeated by chance. */
21
+ const RESTART_MIN = 12;
22
+ /**
23
+ * Whether the continuation began the answer again rather than carrying it on — how a server that
24
+ * ignores the prefill shows itself, since it takes the request without complaint.
25
+ */
26
+ const restarted = (answer, continuation) => {
27
+ const opening = answer.trimStart().slice(0, RESTART_PROBE);
28
+ return opening.length >= RESTART_MIN && continuation.trimStart().startsWith(opening);
29
+ };
30
+ /** The fields of a usage that add across two requests, when both reported them. */
31
+ const ADDED = [
32
+ "uncached",
33
+ "reasoningTokens",
34
+ "promptMs",
35
+ "predictedMs",
36
+ "draftTotal",
37
+ "draftAccepted",
38
+ "wallMs",
39
+ "retries",
40
+ "timeouts",
41
+ ];
42
+ /** A rate, and the duration it was measured over, which is what weights it in a mean. */
43
+ const RATES = [
44
+ ["promptTokensPerSecond", "promptMs"],
45
+ ["tokensPerSecond", "predictedMs"],
46
+ ];
47
+ /**
48
+ * Two requests' usage as one turn's. The first request's own measurements and the loop's
49
+ * comparison with the request before it stay as they were; a field only one of them reported is
50
+ * dropped rather than passed off as the total.
51
+ */
52
+ function joinUsage(first, next) {
53
+ const joined = {
54
+ ...first,
55
+ prompt: first.prompt + next.prompt,
56
+ completion: first.completion + next.completion,
57
+ total: first.total + next.total,
58
+ cached: first.cached + next.cached,
59
+ continuations: (first.continuations ?? 0) + 1,
60
+ };
61
+ for (const field of ADDED) {
62
+ const a = first[field];
63
+ const b = next[field];
64
+ if (a !== undefined && b !== undefined)
65
+ joined[field] = a + b;
66
+ else
67
+ delete joined[field];
68
+ }
69
+ for (const [rate, over] of RATES) {
70
+ const a = first[rate];
71
+ const b = next[rate];
72
+ const aMs = first[over];
73
+ const bMs = next[over];
74
+ // Tokens over time for both together, which is each rate weighted by the time it held.
75
+ if (a !== undefined && b !== undefined && aMs !== undefined && bMs !== undefined && aMs + bMs)
76
+ joined[rate] = (a * aMs + b * bMs) / (aMs + bMs);
77
+ else
78
+ delete joined[rate];
79
+ }
80
+ return joined;
81
+ }
82
+ /** Whether a failure is the endpoint refusing the request as written, rather than losing it. */
83
+ const refusesRequest = (error) => error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
84
+ /**
85
+ * Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
86
+ * so far as a trailing assistant message, and joins the pieces into one turn.
87
+ *
88
+ * Only a turn `isContinuable` accepts is continued; any other comes back as it was. Content and
89
+ * reasoning are joined in order, the tool calls a continuation makes are kept, and usage is summed
90
+ * across the requests with `continuations` counting them. The continuation is read as starting in
91
+ * the answer, whatever `startInReasoning` says: a template that opens a fence for a fresh reply
92
+ * does not open one for a prefill. Its tokens reach `onOutput` as they arrive, so a watcher sees
93
+ * one answer carry on rather than two.
94
+ *
95
+ * Whether the server continues at all is latched per model as `assistantPrefill`. A refusal of the
96
+ * request latches it off, and so does a continuation that begins the answer again, which is how a
97
+ * server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
98
+ * that check is only as good as a restart being word for word. Either way the answer so far is
99
+ * kept, with a notice. So is it when the continuation fails any other way, since the tokens
100
+ * already in hand are worth more than the error; only a stop is thrown.
101
+ *
102
+ * @param client The pooled client for this endpoint.
103
+ * @param supports What the endpoint has already refused.
104
+ * @param request Builds the body the cut-off turn was sent, exactly as `runTurn` was given it. The
105
+ * prefill is appended to what it builds.
106
+ * @param turn The turn that came back cut off.
107
+ * @param options `runTurn`'s options, with `model` needed for the latch — without one nothing is
108
+ * latched and each continuation finds out again — and the cap on continuations.
109
+ */
110
+ export async function continueTurn(client, supports, request, turn, { maxContinuations = 1, ...options } = {}) {
111
+ const refused = options.model === undefined ? undefined : modelCapabilitiesFor(supports, options.model);
112
+ const who = options.model ?? "the model";
113
+ let joined = turn;
114
+ for (let count = 0; count < maxContinuations && isContinuable(joined); count++) {
115
+ if (refused?.assistantPrefill === false)
116
+ break;
117
+ const answer = joined.content;
118
+ let next;
119
+ try {
120
+ next = await runTurn(client, supports, (capabilities, forModel) => {
121
+ const body = request(capabilities, forModel);
122
+ return {
123
+ ...body,
124
+ messages: [...body.messages, { role: "assistant", content: answer }],
125
+ };
126
+ }, { ...options, startInReasoning: false });
127
+ }
128
+ catch (error) {
129
+ if (options.signal?.aborted)
130
+ throw error;
131
+ if (error instanceof ContextOverflow) {
132
+ options.onNotice?.("no room left in the window to continue the cut-off reply");
133
+ }
134
+ else if (refusesRequest(error)) {
135
+ if (refused)
136
+ refused.assistantPrefill = false;
137
+ options.onNotice?.(`${who} refused a trailing assistant message (${errorMessage(error)}); keeping the cut-off reply`);
138
+ }
139
+ else {
140
+ options.onNotice?.(`could not continue the cut-off reply: ${errorMessage(error)}`);
141
+ }
142
+ break;
143
+ }
144
+ if (restarted(answer, next.content)) {
145
+ if (refused)
146
+ refused.assistantPrefill = false;
147
+ options.onNotice?.(`${who} answered afresh instead of continuing its reply; keeping the cut-off reply`);
148
+ break;
149
+ }
150
+ joined = {
151
+ content: joined.content + next.content,
152
+ toolCalls: next.toolCalls,
153
+ usage: joinUsage(joined.usage, next.usage),
154
+ finishReason: next.finishReason,
155
+ reasoning: joined.reasoning + next.reasoning,
156
+ };
157
+ }
158
+ return joined;
159
+ }
package/dist/events.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { TurnUsage } from "./stream.ts";
1
2
  /**
2
3
  * What a run is doing, while it is doing it.
3
4
  *
@@ -81,7 +82,11 @@ export type RunEventKind =
81
82
  | "tool-result"
82
83
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
83
84
  | "notice"
84
- /** What the run has cost so far, as the endpoint reported it at the end of a turn. */
85
+ /**
86
+ * What the run has cost so far, at the end of every turn, and what that one turn did. Sent
87
+ * whether or not the endpoint reported tokens, since the timings and the cache comparison are
88
+ * measured either way.
89
+ */
85
90
  | "usage"
86
91
  /** The run ended. Always last, and always sent. */
87
92
  | "done";
@@ -99,6 +104,17 @@ export interface RunUsage {
99
104
  * emitting usage before this field existed still compiles; absent reads the same as zero.
100
105
  */
101
106
  cachedTokens?: number;
107
+ /**
108
+ * The turn that carried this report, on its own rather than added in — the half `runMetrics`
109
+ * reads, since a total says nothing about which turn was slow or where the cache broke. Absent
110
+ * from a caller emitting usage of its own.
111
+ */
112
+ turn?: TurnReport;
113
+ }
114
+ /** One turn's usage and measurements as a `usage` event carries them. */
115
+ export interface TurnReport extends TurnUsage {
116
+ /** Why the model stopped, as `Turn.finishReason` says it; `length` is a turn cut short. */
117
+ finishReason: string;
102
118
  }
103
119
  /**
104
120
  * One thing that happened in a run, as a watcher receives it.
@@ -209,3 +225,98 @@ export declare const resetEvents: () => void;
209
225
  * @param events Events in `seq` order, from `history` or collected from `watch`.
210
226
  */
211
227
  export declare function fold(events: RunEvent[]): RunEvent[];
228
+ /** Why a turn's cache broke, as `TurnUsage.cacheBreakReason` names it. */
229
+ type CacheBreakReason = NonNullable<TurnUsage["cacheBreakReason"]>;
230
+ /**
231
+ * A run summed and derived from its events: what it cost, where the time went, and why.
232
+ *
233
+ * The counts are always there, zero when nothing happened. Every other field is absent where no
234
+ * turn reported what it is made of, and summed over the turns that did where only some did —
235
+ * a mean of a number a server never sent would be a number nobody measured.
236
+ */
237
+ export interface RunMetrics {
238
+ /** The caller's own `step` events. */
239
+ steps: number;
240
+ /** Turns of the agent loop, one per `usage` report. */
241
+ turns: number;
242
+ /** Model requests, which is turns plus the continuations joined onto them. */
243
+ requests: number;
244
+ /** Tool results, `load_tools` included. */
245
+ toolCalls: number;
246
+ /** Tool results that came back not ok, by tool name. */
247
+ toolErrors: Record<string, number>;
248
+ /** `load_tools` calls. */
249
+ loadCalls: number;
250
+ /**
251
+ * Tools `load_tools` loaded that were not loaded already. Filled by `runAgentLoop`, which sees
252
+ * the resolution; the events carry only its text.
253
+ */
254
+ toolsLoaded?: number;
255
+ /** Tools the model asked `load_tools` for that it already had, filled the same way. */
256
+ redundantLoads?: number;
257
+ /** Names the model asked `load_tools` for that are in no catalogue, filled the same way. */
258
+ unknownToolNames?: number;
259
+ promptTokens: number;
260
+ completionTokens: number;
261
+ cachedTokens: number;
262
+ /** Summed over the turns that reported a cache count. */
263
+ uncachedTokens?: number;
264
+ reasoningTokens?: number;
265
+ /** Cached over prompt tokens, across only the turns that reported a cache count. */
266
+ cacheHitRatio?: number;
267
+ /** Turns whose cache fell short of what the turn before left it. */
268
+ cacheBreaks: number;
269
+ /** Those turns by what the loop changed. */
270
+ cacheBreakReasons: Partial<Record<CacheBreakReason, number>>;
271
+ /** Turns cut off at `maxTokens` after any continuation. */
272
+ truncatedTurns: number;
273
+ /** From the first event to the last, which a still-running run keeps moving. */
274
+ wallMs?: number;
275
+ /** Prefill time summed over the turns. */
276
+ promptMs?: number;
277
+ /** Decode time summed over the turns. */
278
+ predictedMs?: number;
279
+ /**
280
+ * Time from each tool call to its result, summed per call — so calls run in parallel can add up
281
+ * to more than the wall time they took.
282
+ */
283
+ toolMs?: number;
284
+ /** The longest any one turn took, retries and continuations in. */
285
+ slowestTurnMs?: number;
286
+ /** The mean time to first token over the turns that produced one. */
287
+ firstTokenMs?: number;
288
+ draftTotal?: number;
289
+ draftAccepted?: number;
290
+ /** Accepted over drafted. */
291
+ draftAcceptance?: number;
292
+ /** The biggest prompt any turn reported. */
293
+ largestPrompt?: number;
294
+ /** `largestPrompt` over the window `runMetrics` was told, for how close the run came. */
295
+ largestPromptShare?: number;
296
+ /**
297
+ * How it ended, read off `done` and the last turn: `truncated` is an answer the ceiling cut off.
298
+ * A failure does not say whether it was an error, a stop or the tool budget — that is in the
299
+ * host's own `done` text.
300
+ */
301
+ outcome?: "answered" | "truncated" | "failed";
302
+ }
303
+ /** What `runMetrics` takes besides the events. */
304
+ export interface RunMetricsOptions {
305
+ /** The window the run was served, for `largestPromptShare`. Absent or zero leaves it out. */
306
+ contextLength?: number;
307
+ }
308
+ /**
309
+ * A run's totals, timings and cache findings, derived from the events it emitted.
310
+ *
311
+ * A sibling of `fold` rather than part of it. `fold` hands back events, and a client renders
312
+ * what it returns as blocks; a summary is another shape, and folding one in would give every
313
+ * consumer of `fold` a block it does not know how to draw. Derived from the `usage` reports' own
314
+ * `turn` rather than the running totals, so a run with several loops in it — a question per loop —
315
+ * adds up the same as one with a single loop.
316
+ *
317
+ * @param events A run's events in `seq` order, from `history` or collected from `watch`. A backlog
318
+ * that has lost its oldest events to the cap sums what it still has.
319
+ * @param options The served window, for how full the run came to it.
320
+ */
321
+ export declare function runMetrics(events: RunEvent[], { contextLength }?: RunMetricsOptions): RunMetrics;
322
+ export {};
package/dist/events.js CHANGED
@@ -1,14 +1,4 @@
1
- /**
2
- * What a run is doing, while it is doing it.
3
- *
4
- * A run row only exists as a before and an after: it is written when the agent starts and
5
- * updated when it stops, and everything in between — the thinking, the tool the model reached
6
- * for, the argument it got wrong — is gone by the time anyone can read it. This is that middle,
7
- * kept in memory and handed to whoever is watching.
8
- *
9
- * In memory on purpose: it is debugging output, worth nothing once the run has finished and its
10
- * outcome is in the database. Nothing here survives a restart, and nothing here is the record.
11
- */
1
+ import { LOAD_TOOLS } from "./tool-loading.js";
12
2
  /** The numbers a run of the shape this bus was written for wants. */
13
3
  const DEFAULTS = {
14
4
  maxEvents: 1000,
@@ -348,3 +338,113 @@ export function fold(events) {
348
338
  close();
349
339
  return blocks;
350
340
  }
341
+ /**
342
+ * A run's totals, timings and cache findings, derived from the events it emitted.
343
+ *
344
+ * A sibling of `fold` rather than part of it. `fold` hands back events, and a client renders
345
+ * what it returns as blocks; a summary is another shape, and folding one in would give every
346
+ * consumer of `fold` a block it does not know how to draw. Derived from the `usage` reports' own
347
+ * `turn` rather than the running totals, so a run with several loops in it — a question per loop —
348
+ * adds up the same as one with a single loop.
349
+ *
350
+ * @param events A run's events in `seq` order, from `history` or collected from `watch`. A backlog
351
+ * that has lost its oldest events to the cap sums what it still has.
352
+ * @param options The served window, for how full the run came to it.
353
+ */
354
+ export function runMetrics(events, { contextLength } = {}) {
355
+ const metrics = {
356
+ steps: 0,
357
+ turns: 0,
358
+ requests: 0,
359
+ toolCalls: 0,
360
+ toolErrors: {},
361
+ loadCalls: 0,
362
+ promptTokens: 0,
363
+ completionTokens: 0,
364
+ cachedTokens: 0,
365
+ cacheBreaks: 0,
366
+ cacheBreakReasons: {},
367
+ truncatedTurns: 0,
368
+ };
369
+ /** Adds to a field that is absent until something reports it. */
370
+ const add = (field, value) => {
371
+ if (value === undefined)
372
+ return;
373
+ const known = metrics;
374
+ known[field] = (known[field] ?? 0) + value;
375
+ };
376
+ let reportedPrompt = 0;
377
+ let reportedCached = 0;
378
+ let firstTokens = 0;
379
+ // Calls waiting for their results, by tool name, oldest first.
380
+ const pending = new Map();
381
+ let last;
382
+ for (const event of events) {
383
+ if (event.kind === "step")
384
+ metrics.steps++;
385
+ else if (event.kind === "tool-call") {
386
+ if (event.name === LOAD_TOOLS)
387
+ metrics.loadCalls++;
388
+ const waiting = pending.get(event.name) ?? [];
389
+ waiting.push(event.at);
390
+ pending.set(event.name, waiting);
391
+ }
392
+ else if (event.kind === "tool-result") {
393
+ metrics.toolCalls++;
394
+ if (event.ok === false)
395
+ metrics.toolErrors[event.name] = (metrics.toolErrors[event.name] ?? 0) + 1;
396
+ const called = pending.get(event.name)?.shift();
397
+ if (called !== undefined)
398
+ add("toolMs", event.at - called);
399
+ }
400
+ else if (event.kind === "usage" && event.usage?.turn) {
401
+ const turn = event.usage.turn;
402
+ last = turn;
403
+ metrics.turns++;
404
+ metrics.requests += 1 + (turn.continuations ?? 0);
405
+ metrics.promptTokens += turn.prompt;
406
+ metrics.completionTokens += turn.completion;
407
+ metrics.cachedTokens += turn.cached;
408
+ if (turn.uncached !== undefined) {
409
+ add("uncachedTokens", turn.uncached);
410
+ reportedPrompt += turn.prompt;
411
+ reportedCached += turn.cached;
412
+ }
413
+ add("reasoningTokens", turn.reasoningTokens);
414
+ add("promptMs", turn.promptMs);
415
+ add("predictedMs", turn.predictedMs);
416
+ add("draftTotal", turn.draftTotal);
417
+ add("draftAccepted", turn.draftAccepted);
418
+ if (turn.cacheBroken) {
419
+ metrics.cacheBreaks++;
420
+ const reason = turn.cacheBreakReason ?? "none-known";
421
+ metrics.cacheBreakReasons[reason] = (metrics.cacheBreakReasons[reason] ?? 0) + 1;
422
+ }
423
+ if (turn.finishReason === "length")
424
+ metrics.truncatedTurns++;
425
+ if (turn.wallMs !== undefined)
426
+ metrics.slowestTurnMs = Math.max(metrics.slowestTurnMs ?? 0, turn.wallMs);
427
+ if (turn.firstTokenMs !== undefined) {
428
+ add("firstTokenMs", turn.firstTokenMs);
429
+ firstTokens++;
430
+ }
431
+ if (turn.prompt > 0)
432
+ metrics.largestPrompt = Math.max(metrics.largestPrompt ?? 0, turn.prompt);
433
+ }
434
+ else if (event.kind === "done") {
435
+ metrics.outcome =
436
+ event.ok === false ? "failed" : last?.finishReason === "length" ? "truncated" : "answered";
437
+ }
438
+ }
439
+ if (events.length > 1)
440
+ metrics.wallMs = events[events.length - 1].at - events[0].at;
441
+ if (reportedPrompt > 0)
442
+ metrics.cacheHitRatio = reportedCached / reportedPrompt;
443
+ if (metrics.firstTokenMs !== undefined)
444
+ metrics.firstTokenMs /= firstTokens;
445
+ if (metrics.draftTotal && metrics.draftAccepted !== undefined)
446
+ metrics.draftAcceptance = metrics.draftAccepted / metrics.draftTotal;
447
+ if (metrics.largestPrompt !== undefined && contextLength && contextLength > 0)
448
+ metrics.largestPromptShare = metrics.largestPrompt / contextLength;
449
+ return metrics;
450
+ }
package/dist/hooks.d.ts CHANGED
@@ -89,6 +89,12 @@ export interface HookOutcome {
89
89
  inject: boolean;
90
90
  /** The most of `text` that is injected, in estimated tokens. */
91
91
  maxTokens: number;
92
+ /**
93
+ * Asks that what the event announces not happen. Only a `beforeCompact` hook's is read, and
94
+ * only by a host that waits for it — see `consult` — and never on an outcome that is not `ok`,
95
+ * since a hook that crashed has not said anything.
96
+ */
97
+ veto?: boolean;
92
98
  }
93
99
  /**
94
100
  * One hook's line for whoever is watching: the context it added, or why it added none. A hook
@@ -105,6 +111,8 @@ export interface HookNote {
105
111
  text?: string;
106
112
  /** Why it added nothing: it failed, timed out, or never ran. */
107
113
  error?: string;
114
+ /** Set when the hook vetoed what the event announced, and was waited for. See `consult`. */
115
+ veto?: true;
108
116
  }
109
117
  /**
110
118
  * Runs one event's hooks. Should resolve rather than reject — a hook failing is an outcome — but
@@ -248,26 +256,44 @@ export declare function untrusted(text: string, { source }?: {
248
256
  * two identical "ok"s one memory. The same message sent twice, after its turn and again when it
249
257
  * is compacted, is one.
250
258
  *
259
+ * That position is the array's, which is the host's only while the two agree. Once a compaction
260
+ * has folded the head of a request into one summary message, the same message sits at a lower
261
+ * index there than it does in a transcript the host kept whole — so the same turn, sent after it
262
+ * and again when it is compacted, would arrive under two uuids and be remembered twice. `offset`
263
+ * is what puts the numbering back on the host's own indexes; passing the stored transcript rather
264
+ * than the request avoids the question entirely.
265
+ *
251
266
  * @param sessionId Prefixes every uuid, so two sessions never share one.
252
267
  * @param messages The transcript, in whatever shape the host stores it, so long as each message
253
268
  * has an OpenAI-style `role` and `content`.
254
269
  * @param from The first index, inclusive. Below zero reads from the start.
255
270
  * @param to The end, exclusive. Absent, or past the end, reads to the end.
271
+ * @param options `offset` is what the array's first message is numbered as in the uuids — the
272
+ * stored index of `messages[0]`, when `messages` is a request a fold has shifted. Zero by default,
273
+ * which numbers by position as before. `from` and `to` stay array indexes either way.
256
274
  */
257
275
  export declare function turnMessages(sessionId: string, messages: readonly {
258
276
  role: string;
259
277
  content?: unknown;
260
- }[], from: number, to?: number): HookMessage[];
278
+ }[], from: number, to?: number, { offset }?: {
279
+ offset?: number;
280
+ }): HookMessage[];
261
281
  /**
262
282
  * Which turn of a session begins at a point, from 0: the user messages ahead of it.
263
283
  *
284
+ * Counted over what it is given, which is the session only while nothing has been folded away — a
285
+ * compacted request has lost the questions the summary now stands for, and turn eleven counting
286
+ * itself as turn two is the kind of thing a hook writes into a memory. Count over the stored
287
+ * transcript, or add what the fold took as `offset`.
288
+ *
264
289
  * @param messages The transcript.
265
290
  * @param before Where the turn begins. Absent is the end, which is the index of a turn whose
266
291
  * question has not been appended yet.
292
+ * @param offset Turns already folded away and so not in `messages`. Zero by default.
267
293
  */
268
294
  export declare const turnIndex: (messages: readonly {
269
295
  role: string;
270
- }[], before?: number) => number;
296
+ }[], before?: number, offset?: number) => number;
271
297
  /**
272
298
  * Runs the hooks ahead of a request and builds what they add to it.
273
299
  *
@@ -297,7 +323,8 @@ export declare function gather(run: HookRunner, events: readonly HookEvent[], co
297
323
  * rejects, so a host can fire it without awaiting it.
298
324
  *
299
325
  * No signal: these run once the turn has been answered, and a reader who stops listening at that
300
- * point has not asked for the turn not to be remembered.
326
+ * point has not asked for the turn not to be remembered. Nor is a `veto` read, since whatever it
327
+ * would stop is already under way; `consult` is the one that waits for it.
301
328
  *
302
329
  * @param run Runs the event's hooks.
303
330
  * @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
@@ -307,3 +334,22 @@ export declare function gather(run: HookRunner, events: readonly HookEvent[], co
307
334
  * @returns The same notes.
308
335
  */
309
336
  export declare function notify(run: HookRunner, event: HookEvent, context: HookContext, onNote?: (note: HookNote) => void): Promise<HookNote[]>;
337
+ /**
338
+ * Runs an event's hooks and waits for their say, for a host that will hold off when one of them
339
+ * vetoes.
340
+ *
341
+ * `notify` runs beside the thing it announces and cannot stop it; this runs ahead of it, so each
342
+ * hook's time is added to whatever waits on the answer. A host that does not mean to act on a
343
+ * veto should call `notify` instead. Never rejects: a runner that throws is noted as a failure,
344
+ * and a failure is not a veto — a memory server that is down has not asked for anything.
345
+ *
346
+ * @param run Runs the event's hooks.
347
+ * @param event What is about to happen. Only `beforeCompact` has anything a veto can stop.
348
+ * @param context What the hooks are told.
349
+ * @param onNote Hears each note: every failure, and every veto, naming the hook that made it.
350
+ * @returns The same notes, and `vetoed` when any `ok` outcome carried `veto`.
351
+ */
352
+ export declare function consult(run: HookRunner, event: HookEvent, context: HookContext, onNote?: (note: HookNote) => void): Promise<{
353
+ notes: HookNote[];
354
+ vetoed: boolean;
355
+ }>;
package/dist/hooks.js CHANGED
@@ -204,13 +204,23 @@ const textOf = (content) => {
204
204
  * two identical "ok"s one memory. The same message sent twice, after its turn and again when it
205
205
  * is compacted, is one.
206
206
  *
207
+ * That position is the array's, which is the host's only while the two agree. Once a compaction
208
+ * has folded the head of a request into one summary message, the same message sits at a lower
209
+ * index there than it does in a transcript the host kept whole — so the same turn, sent after it
210
+ * and again when it is compacted, would arrive under two uuids and be remembered twice. `offset`
211
+ * is what puts the numbering back on the host's own indexes; passing the stored transcript rather
212
+ * than the request avoids the question entirely.
213
+ *
207
214
  * @param sessionId Prefixes every uuid, so two sessions never share one.
208
215
  * @param messages The transcript, in whatever shape the host stores it, so long as each message
209
216
  * has an OpenAI-style `role` and `content`.
210
217
  * @param from The first index, inclusive. Below zero reads from the start.
211
218
  * @param to The end, exclusive. Absent, or past the end, reads to the end.
219
+ * @param options `offset` is what the array's first message is numbered as in the uuids — the
220
+ * stored index of `messages[0]`, when `messages` is a request a fold has shifted. Zero by default,
221
+ * which numbers by position as before. `from` and `to` stay array indexes either way.
212
222
  */
213
- export function turnMessages(sessionId, messages, from, to) {
223
+ export function turnMessages(sessionId, messages, from, to, { offset = 0 } = {}) {
214
224
  const end = Math.min(to ?? messages.length, messages.length);
215
225
  const out = [];
216
226
  for (let at = Math.max(0, from); at < end; at++) {
@@ -221,18 +231,28 @@ export function turnMessages(sessionId, messages, from, to) {
221
231
  if (!text)
222
232
  continue;
223
233
  const digest = createHash("sha256").update(`${message.role}\0${text}`).digest("hex");
224
- out.push({ speaker: message.role, text, uuid: `${sessionId}:${at}:${digest.slice(0, 12)}` });
234
+ out.push({
235
+ speaker: message.role,
236
+ text,
237
+ uuid: `${sessionId}:${at + offset}:${digest.slice(0, 12)}`,
238
+ });
225
239
  }
226
240
  return out;
227
241
  }
228
242
  /**
229
243
  * Which turn of a session begins at a point, from 0: the user messages ahead of it.
230
244
  *
245
+ * Counted over what it is given, which is the session only while nothing has been folded away — a
246
+ * compacted request has lost the questions the summary now stands for, and turn eleven counting
247
+ * itself as turn two is the kind of thing a hook writes into a memory. Count over the stored
248
+ * transcript, or add what the fold took as `offset`.
249
+ *
231
250
  * @param messages The transcript.
232
251
  * @param before Where the turn begins. Absent is the end, which is the index of a turn whose
233
252
  * question has not been appended yet.
253
+ * @param offset Turns already folded away and so not in `messages`. Zero by default.
234
254
  */
235
- export const turnIndex = (messages, before = messages.length) => messages.slice(0, before).filter((message) => message.role === "user").length;
255
+ export const turnIndex = (messages, before = messages.length, offset = 0) => offset + messages.slice(0, before).filter((message) => message.role === "user").length;
236
256
  /** A runner that rejected, as the one outcome its event can still be noted by. */
237
257
  const rejected = (event, error) => ({
238
258
  serverId: "",
@@ -279,7 +299,8 @@ export async function gather(run, events, context, { signal, onNote, maxTokens,
279
299
  * rejects, so a host can fire it without awaiting it.
280
300
  *
281
301
  * No signal: these run once the turn has been answered, and a reader who stops listening at that
282
- * point has not asked for the turn not to be remembered.
302
+ * point has not asked for the turn not to be remembered. Nor is a `veto` read, since whatever it
303
+ * would stop is already under way; `consult` is the one that waits for it.
283
304
  *
284
305
  * @param run Runs the event's hooks.
285
306
  * @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
@@ -297,3 +318,37 @@ export async function notify(run, event, context, onNote) {
297
318
  onNote?.(note);
298
319
  return notes;
299
320
  }
321
+ /**
322
+ * Runs an event's hooks and waits for their say, for a host that will hold off when one of them
323
+ * vetoes.
324
+ *
325
+ * `notify` runs beside the thing it announces and cannot stop it; this runs ahead of it, so each
326
+ * hook's time is added to whatever waits on the answer. A host that does not mean to act on a
327
+ * veto should call `notify` instead. Never rejects: a runner that throws is noted as a failure,
328
+ * and a failure is not a veto — a memory server that is down has not asked for anything.
329
+ *
330
+ * @param run Runs the event's hooks.
331
+ * @param event What is about to happen. Only `beforeCompact` has anything a veto can stop.
332
+ * @param context What the hooks are told.
333
+ * @param onNote Hears each note: every failure, and every veto, naming the hook that made it.
334
+ * @returns The same notes, and `vetoed` when any `ok` outcome carried `veto`.
335
+ */
336
+ export async function consult(run, event, context, onNote) {
337
+ const outcomes = await runSafely(run, event, context);
338
+ const notes = [];
339
+ for (const outcome of outcomes) {
340
+ if (!outcome.ok)
341
+ notes.push(assembleContext([outcome]).notes[0]);
342
+ else if (outcome.veto === true) {
343
+ notes.push({
344
+ event: outcome.event,
345
+ source: outcome.label,
346
+ hookId: outcome.hookId,
347
+ veto: true,
348
+ });
349
+ }
350
+ }
351
+ for (const note of notes)
352
+ onNote?.(note);
353
+ return { notes, vetoed: notes.some((note) => note.veto) };
354
+ }
package/dist/index.d.ts CHANGED
@@ -10,16 +10,18 @@
10
10
  * that differs between one server and the next.
11
11
  */
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
+ export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
13
14
  export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
15
  export type { CatalogServer } from "./catalog.ts";
15
16
  export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
16
- export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
17
+ export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
17
18
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
19
+ export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./continuation.ts";
18
20
  export { errorMessage } from "./errors.ts";
19
- export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
20
- export { assembleContext, configureHooks, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
21
+ export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
+ export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
21
23
  export { resetAll } from "./reset.ts";
22
- export { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
24
+ export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
23
25
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
24
26
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
25
27
  export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
@@ -28,4 +30,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
28
30
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
29
31
  export { estimateTokens } from "./tokens.ts";
30
32
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
31
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
33
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, type ToolOrder, } from "./tool-loading.ts";