@cubicecho/agent-core 2.23.0 → 2.25.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.
Files changed (71) hide show
  1. package/README.md +14 -9
  2. package/dist/agent-loop.d.ts +84 -204
  3. package/dist/agent-loop.js +416 -643
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -22
  6. package/dist/capabilities.d.ts +140 -35
  7. package/dist/capabilities.js +345 -152
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +210 -78
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +163 -93
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +25 -17
  15. package/dist/continuation.js +70 -47
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +108 -58
  21. package/dist/events.js +265 -128
  22. package/dist/guards.d.ts +53 -0
  23. package/dist/guards.js +55 -0
  24. package/dist/hook-events.d.ts +38 -0
  25. package/dist/hook-events.js +36 -0
  26. package/dist/hooks.d.ts +105 -83
  27. package/dist/hooks.js +224 -139
  28. package/dist/index.d.ts +30 -25
  29. package/dist/index.js +29 -23
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +40 -143
  41. package/dist/retry.js +79 -285
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +44 -16
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +51 -11
  49. package/dist/schema-compat.js +375 -193
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +127 -39
  53. package/dist/side-task.js +189 -100
  54. package/dist/snapshot.d.ts +12 -7
  55. package/dist/snapshot.js +87 -65
  56. package/dist/spec.d.ts +66 -36
  57. package/dist/spec.js +514 -265
  58. package/dist/stream.d.ts +73 -16
  59. package/dist/stream.js +228 -98
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +96 -46
  62. package/dist/tokens.d.ts +171 -6
  63. package/dist/tokens.js +326 -6
  64. package/dist/tool-calls.d.ts +89 -15
  65. package/dist/tool-calls.js +205 -85
  66. package/dist/tool-loading.d.ts +109 -187
  67. package/dist/tool-loading.js +286 -372
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +87 -43
  71. package/package.json +4 -4
package/dist/events.js CHANGED
@@ -1,17 +1,28 @@
1
- import { scoped } from "./scope.js";
2
- import { LOAD_TOOLS } from "./tool-loading.js";
1
+ import { getOrCreate } from './guards.js';
2
+ import { ABORT_EVENT, MS_PER_MINUTE } from './platform.js';
3
+ import { assignSettings, scoped } from './scope.js';
4
+ import { CacheBreakReason } from './stream.js';
5
+ import { LOAD_TOOLS } from './tool-loading.js';
6
+ import { FinishReason } from './wire.js';
7
+ /** How many minutes an unended run's events are kept, absent a `retainUnendedMs`. */
8
+ const RETAIN_UNENDED_MINUTES = 30;
3
9
  /** The numbers a run of the shape this bus was written for wants. */
4
10
  const DEFAULTS = {
5
11
  maxEvents: 1000,
6
12
  trimSlack: 256,
7
- retainMs: 60_000,
8
- retainUnendedMs: 30 * 60_000,
13
+ retainMs: MS_PER_MINUTE,
14
+ retainUnendedMs: RETAIN_UNENDED_MINUTES * MS_PER_MINUTE,
9
15
  };
10
16
  const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweeping: null }));
11
17
  /**
12
18
  * Changes what the bus keeps, for a process whose runs are not shaped like the ones these
13
19
  * defaults were chosen for.
14
20
  *
21
+ * @param [options] - The bounds to change. A field left out — or given anything that is not a
22
+ * number above zero — keeps what it has, so a partial or a half-built config narrows nothing.
23
+ * @returns Everything in force afterwards, including what this call did not change.
24
+ *
25
+ * @remarks
15
26
  * The bus is one thing per runtime rather than an object a caller holds, so this is too: it is
16
27
  * a deployment's setting, said once at startup, and not something to move around under a run.
17
28
  * Called at the top level that is the process's bus; a runtime made by `createRuntime` has its
@@ -23,36 +34,83 @@ const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweepin
23
34
  * Changes apply from the next event and the next sweep. Nothing already buffered is trimmed to
24
35
  * a cap that has just come down, because the trim happens on push; the backlog settles to the
25
36
  * new number as the run goes on.
26
- *
27
- * @param options The bounds to change. A field left out — or given anything that is not a
28
- * number above zero — keeps what it has, so a partial or a half-built config narrows nothing.
29
- * @returns Everything in force afterwards, including what this call did not change.
30
37
  */
31
38
  export function configureEvents(options = {}) {
32
- const { limits } = bus();
33
- for (const [name, value] of Object.entries(options)) {
34
- if (typeof value === "number" && value > 0)
35
- limits[name] = value;
36
- }
37
- return { ...limits };
39
+ return assignSettings(bus().limits, options);
38
40
  }
39
- const streamFor = ({ streams }, runId) => {
40
- const existing = streams.get(runId);
41
- if (existing)
42
- return existing;
43
- const stream = {
44
- events: [],
45
- listeners: new Set(),
46
- seq: 0,
47
- touched: Date.now(),
48
- ended: false,
49
- };
50
- streams.set(runId, stream);
51
- return stream;
41
+ /** Which kind of thing happened, and what `text`, `name`, `ok` and `usage` carry for it. */
42
+ export const RunEventKind = {
43
+ /** A step of a caller's own flow began. `name` is the step, `text` its kind. */
44
+ Step: 'step',
45
+ /** A decision step chose an arm. `text` is the arm it took. */
46
+ Decision: 'decision',
47
+ /** A new turn of the agent loop began, inside whichever step is running. */
48
+ Turn: 'turn',
49
+ /** Reasoning tokens, as they arrive. */
50
+ Thinking: 'thinking',
51
+ /** Reply tokens, as they arrive. */
52
+ Output: 'output',
53
+ /** The model asked for a tool, with the arguments it chose. `id` is the call's. */
54
+ ToolCall: 'tool-call',
55
+ /** A tool came back, with what it said. `id` is the id of the call it answers. */
56
+ ToolResult: 'tool-result',
57
+ /** Something the runner did that is not the model's doing — a preselection, a retry. */
58
+ Notice: 'notice',
59
+ /**
60
+ * What the run has cost so far, at the end of every turn, and what that one turn did. Sent
61
+ * whether or not the endpoint reported tokens, since the timings and the cache comparison are
62
+ * measured either way.
63
+ */
64
+ Usage: 'usage',
65
+ /** The run ended. Always last, and always sent. */
66
+ Done: 'done',
52
67
  };
68
+ /**
69
+ * A whole event from what a caller said of it: the empty defaults under it, the run and sequence over it.
70
+ *
71
+ * @param input - What happened. An `at` it carries wins over the one given here.
72
+ * @param runId - The run it belongs to. Written after `input`, which gets no say in it.
73
+ * @param seq - Its place in that run, the same.
74
+ * @param at - When, for an input that does not say.
75
+ * @returns A new event; `input` is not written to.
76
+ *
77
+ * @remarks
78
+ * The one place the defaults are written, so an event the bus stamps, one the loop keeps for its
79
+ * own metrics and the notice a slow watcher is sent cannot come to disagree about what an unset
80
+ * field reads as.
81
+ */
82
+ export const stamp = (input, runId, seq, at) => ({
83
+ at,
84
+ text: '',
85
+ name: '',
86
+ step: '',
87
+ ok: null,
88
+ usage: null,
89
+ ...input,
90
+ runId,
91
+ seq,
92
+ });
93
+ /**
94
+ * A run's stream on a bus, begun empty where the run has none.
95
+ *
96
+ * @param held - The bus to look on. Gains an entry on a miss.
97
+ * @param runId - The run whose stream is wanted.
98
+ * @returns The stream the bus holds, not a copy — writing to it is writing to the run.
99
+ */
100
+ const streamFor = ({ streams }, runId) => getOrCreate(streams, runId, () => ({
101
+ events: [],
102
+ listeners: new Set(),
103
+ seq: 0,
104
+ touched: Date.now(),
105
+ ended: false,
106
+ }));
53
107
  /**
54
108
  * Drops the streams nobody is reading and nothing is writing to.
55
109
  *
110
+ * @param held - The bus to sweep. Loses each unwatched stream untouched for its retention or
111
+ * longer, and has its timer cleared and — while any stream is left — set again.
112
+ *
113
+ * @remarks
56
114
  * Cleanup used to hang entirely off `done`, which assumed every run reaches it. A run killed by
57
115
  * an uncaught throw, a signal, or a caller that simply forgets pinned its backlog for the life
58
116
  * of the process — and in a long-lived server that map only ever grew. The `done` timer had the
@@ -67,16 +125,25 @@ function sweep(held) {
67
125
  held.sweeping = null;
68
126
  const now = Date.now();
69
127
  for (const [runId, stream] of streams) {
70
- if (stream.listeners.size)
128
+ if (stream.listeners.size) {
71
129
  continue;
72
- if (stream.touched <= now - (stream.ended ? limits.retainMs : limits.retainUnendedMs))
130
+ }
131
+ if (stream.touched <= now - (stream.ended ? limits.retainMs : limits.retainUnendedMs)) {
73
132
  streams.delete(runId);
133
+ }
74
134
  }
75
135
  scheduleSweep(held);
76
136
  }
137
+ /**
138
+ * Arms a bus's one sweep timer, unless it is armed already or the bus holds no stream to expire.
139
+ *
140
+ * @param held - The bus. Its `sweeping` is set to a timer that fires after `retainMs` as it stands
141
+ * now, and that does not keep the process alive.
142
+ */
77
143
  function scheduleSweep(held) {
78
- if (held.sweeping || held.streams.size === 0)
144
+ if (held.sweeping || held.streams.size === 0) {
79
145
  return;
146
+ }
80
147
  held.sweeping = setTimeout(() => sweep(held), held.limits.retainMs);
81
148
  held.sweeping.unref?.();
82
149
  }
@@ -85,28 +152,32 @@ function scheduleSweep(held) {
85
152
  * loop threw where it could not be caught. The sweep gets there on its own; this is for a
86
153
  * caller that already knows.
87
154
  *
88
- * @param runId The run to forget. An id nothing was emitted under is ignored.
155
+ * @param runId - The run to forget. An id nothing was emitted under is ignored.
89
156
  */
90
157
  export function endRun(runId) {
91
158
  const { streams } = bus();
92
159
  const stream = streams.get(runId);
93
- if (!stream)
160
+ if (!stream) {
94
161
  return;
162
+ }
95
163
  // Watchers are parked on a promise that only an `emit` to *this* stream can resolve, and the
96
164
  // delete below puts it beyond the reach of every later one — the next `emit` builds a fresh
97
165
  // stream and wakes nobody. So they are told the run is over first: a watcher that is handed
98
166
  // `done` completes, runs its `finally` and lets its consumer go, where one left parked holds
99
167
  // an open subscription that can never say anything again. An SSE client on the other end of
100
168
  // that is a connection that never closes.
101
- if (stream.listeners.size)
102
- emit(runId, { kind: "done", ok: false, text: "run ended" });
169
+ if (stream.listeners.size) {
170
+ emit(runId, { kind: RunEventKind.Done, ok: false, text: 'run ended' });
171
+ }
103
172
  streams.delete(runId);
104
173
  }
105
174
  /**
106
175
  * Records one event and hands it to everyone watching that run. Never throws at the caller.
107
176
  *
108
- * @param runId The run this belongs to. Created on first use.
109
- * @param input The event. `kind` is required; `runId` and `seq` are not a caller's to set.
177
+ * @param runId - The run this belongs to. Created on first use.
178
+ * @param input - The event. `kind` is required; `runId` and `seq` are not a caller's to set.
179
+ * @returns The event as it was recorded: its `seq` in the run, its time, and every unset field
180
+ * filled in.
110
181
  */
111
182
  export function emit(runId, input) {
112
183
  const held = bus();
@@ -114,18 +185,7 @@ export function emit(runId, input) {
114
185
  const stream = streamFor(held, runId);
115
186
  // One clock read, used for both the event and the sweep's bookkeeping.
116
187
  const at = Date.now();
117
- const event = {
118
- at,
119
- text: "",
120
- name: "",
121
- step: "",
122
- ok: null,
123
- usage: null,
124
- ...input,
125
- // After the spread, not before: these are the bus's and a caller does not get a say.
126
- runId,
127
- seq: ++stream.seq,
128
- };
188
+ const event = stamp(input, runId, ++stream.seq, at);
129
189
  stream.events.push(event);
130
190
  stream.touched = at;
131
191
  if (stream.events.length > limits.maxEvents + limits.trimSlack) {
@@ -133,8 +193,9 @@ export function emit(runId, input) {
133
193
  }
134
194
  // Kept for a moment so a watcher that arrives just after the end still sees how it went,
135
195
  // then dropped: a finished run's record is the row, not this.
136
- if (event.kind === "done")
196
+ if (event.kind === RunEventKind.Done) {
137
197
  stream.ended = true;
198
+ }
138
199
  scheduleSweep(held);
139
200
  for (const listener of stream.listeners) {
140
201
  // The guarantee above is the point of this: a listener that throws must not take out the
@@ -149,22 +210,57 @@ export function emit(runId, input) {
149
210
  /**
150
211
  * Everything that has happened on a run, then everything that happens next, until it ends.
151
212
  *
152
- * The backlog comes first so a watcher that joins halfway through — or after the run finished,
153
- * inside the retention window — reads the same story as one that was there from the start.
154
- *
155
- * @param runId The run to follow. One that has not started yet is waited on, not refused.
156
- * @param signal Stops following. The only other way out is the run's own `done`, and a watcher
213
+ * @param runId - The run to follow. One that has not started yet is waited on, not refused.
214
+ * @param [signal] - Stops following. The only other way out is the run's own `done`, and a watcher
157
215
  * with no way out is a leak rather than a lost backlog: the sweep below skips any stream a
158
216
  * listener is on, so a run that dies without `done` pins its backlog for the life of the process.
159
217
  * Returning the generator is not that way out — parked on the promise at the foot of this
160
218
  * function it is suspended at an `await` rather than at a `yield`, and a `return()` there is
161
219
  * queued behind a promise only the next event can settle. An abort resolves that promise itself.
220
+ * @returns The run's events in order, ending after its `done` or at the abort. A watcher that fell
221
+ * too far behind is given one notice in place of the events it missed. Nothing is subscribed until
222
+ * the first event is asked for.
223
+ *
224
+ * @remarks
225
+ * The backlog comes first so a watcher that joins halfway through — or after the run finished,
226
+ * inside the retention window — reads the same story as one that was there from the start.
162
227
  */
163
228
  export function watch(runId, signal) {
164
229
  // Read here, not in the generator: its body runs when the first event is asked for, and by
165
230
  // then the caller may be outside the runtime whose run this is.
166
231
  return watching(bus(), runId, signal);
167
232
  }
233
+ /**
234
+ * The notice a watcher that fell behind is given in place of the events it missed.
235
+ *
236
+ * @param runId - The run the watcher is following.
237
+ * @param gap - How many events were dropped, for the text.
238
+ * @param next - The event the notice goes ahead of, which gives it its step, its time and — less
239
+ * one — its seq.
240
+ * @returns A `notice` event made for that one watcher: it is in no backlog.
241
+ *
242
+ * @remarks
243
+ * One short of the event it precedes, which is the last seq that went missing. Sharing a seq
244
+ * with the event behind it made the notice indistinguishable from a duplicate, and de-duplicating
245
+ * on `seq` is the one thing the sequence is documented for — so a client doing exactly that
246
+ * dropped either the gap notice or the event it explains. Inside the gap there is nothing to
247
+ * collide with: those seqs reach no watcher.
248
+ */
249
+ const gapNotice = (runId, gap, next) => stamp({
250
+ kind: RunEventKind.Notice,
251
+ text: `${gap} event(s) dropped: this watcher fell too far behind`,
252
+ step: next.step,
253
+ }, runId, next.seq - 1, next.at);
254
+ /**
255
+ * The generator behind `watch`, on a bus already chosen.
256
+ *
257
+ * @param held - The bus the run is on, kept for the generator's life whichever runtime resumes it.
258
+ * @param runId - The run to follow. A stream is begun for one that has none, and dropped on the way
259
+ * out where this was its last watcher and the run has ended or never emitted.
260
+ * @param [signal] - Stops following. One already aborted yields nothing, the backlog included.
261
+ * @returns The backlog as it stood at the first `next()`, then each event as it is emitted, ending
262
+ * after `done` or at the abort.
263
+ */
168
264
  async function* watching(held, runId, signal) {
169
265
  const { limits, streams } = held;
170
266
  const stream = streamFor(held, runId);
@@ -198,7 +294,7 @@ async function* watching(held, runId, signal) {
198
294
  // The same wake the listener uses: an abort is another reason to stop waiting, and what the
199
295
  // loop does about it is decided in one place below rather than here.
200
296
  const onAbort = () => wake?.();
201
- signal?.addEventListener("abort", onAbort, { once: true });
297
+ signal?.addEventListener(ABORT_EVENT, onAbort, { once: true });
202
298
  try {
203
299
  for (;;) {
204
300
  // Guarding the drain rather than sitting after it, so an already-aborted signal leaves
@@ -220,34 +316,20 @@ async function* watching(held, runId, signal) {
220
316
  if (dropped > 0) {
221
317
  // Said once per gap rather than per event, and before the event that follows it, so a
222
318
  // client reading `seq` sees why the numbers jump instead of assuming it lost its place.
223
- //
224
- // One short of the event it precedes, which is the last seq that went missing. Sharing
225
- // a seq with the event behind it made the notice indistinguishable from a duplicate,
226
- // and de-duplicating on `seq` is the one thing the sequence is documented for — so a
227
- // client doing exactly that dropped either the gap notice or the event it explains.
228
- // Inside the gap there is nothing to collide with: those seqs reach no watcher.
229
319
  const gap = dropped;
230
320
  dropped = 0;
231
- yield {
232
- runId,
233
- seq: event.seq - 1,
234
- at: event.at,
235
- kind: "notice",
236
- text: `${gap} event(s) dropped: this watcher fell too far behind`,
237
- name: "",
238
- step: event.step,
239
- ok: null,
240
- usage: null,
241
- };
321
+ yield gapNotice(runId, gap, event);
242
322
  }
243
323
  yield event;
244
324
  // `done` is the last event a run will ever have, so the subscription completes rather
245
325
  // than leaving the client holding an open stream that will never say anything again.
246
- if (event.kind === "done")
326
+ if (event.kind === RunEventKind.Done) {
247
327
  return;
328
+ }
248
329
  }
249
- if (signal?.aborted)
330
+ if (signal?.aborted) {
250
331
  return;
332
+ }
251
333
  await new Promise((resolve) => {
252
334
  wake = resolve;
253
335
  });
@@ -258,7 +340,7 @@ async function* watching(held, runId, signal) {
258
340
  // `once` covers the abort that fired; this is for the one that never did, which would
259
341
  // otherwise hold this generator and its queue alive for as long as the caller holds the
260
342
  // signal — a run's whole backlog kept by a watcher that finished on `done`.
261
- signal?.removeEventListener("abort", onAbort);
343
+ signal?.removeEventListener(ABORT_EVENT, onAbort);
262
344
  stream.listeners.delete(listener);
263
345
  // A watcher can name a run that has not started, or will never start. Nothing was recorded
264
346
  // under it, so nothing is left behind either — and a run that has ended has nothing more to
@@ -266,9 +348,10 @@ async function* watching(held, runId, signal) {
266
348
  // Against `stream` rather than the id: this generator may have outlived its own entry — a
267
349
  // sweep or an `endRun` drops it and a later `emit` files the same run under a new one — and
268
350
  // the last watcher of the old stream has no business deleting the new one.
269
- if (streams.get(runId) === stream &&
270
- stream.listeners.size === 0 &&
271
- (stream.ended || stream.events.length === 0)) {
351
+ const stillFiled = streams.get(runId) === stream;
352
+ const unwatched = stream.listeners.size === 0;
353
+ const nothingMoreToSay = stream.ended || stream.events.length === 0;
354
+ if (stillFiled && unwatched && nothingMoreToSay) {
272
355
  streams.delete(runId);
273
356
  }
274
357
  }
@@ -276,17 +359,20 @@ async function* watching(held, runId, signal) {
276
359
  /**
277
360
  * The backlog alone, for a caller that wants a snapshot rather than a subscription.
278
361
  *
362
+ * @param runId - The run to read. An unknown or already-swept run gives an empty array.
363
+ * @returns The events still held, oldest first, in an array of the caller's own.
364
+ *
365
+ * @remarks
279
366
  * The array is a copy; the events in it are not. They are the same objects the bus holds and
280
367
  * every watcher was handed, so writing to one rewrites the run for everybody — which is what
281
368
  * `fold` copies to avoid, and this is the other half of the same warning. Read them, or copy
282
369
  * what you mean to change.
283
- *
284
- * @param runId The run to read. An unknown or already-swept run gives an empty array.
285
370
  */
286
371
  export const history = (runId) => [...(bus().streams.get(runId)?.events ?? [])];
287
372
  /**
288
373
  * Test seam: forget every run, so one test's events cannot be read by the next.
289
374
  *
375
+ * @remarks
290
376
  * Named for what it forgets rather than bare `reset`, which sat in a consumer's imports beside
291
377
  * `resetAll`, `resetClients`, `resetCapabilities` and `resetHints` saying nothing about which
292
378
  * of the five it was — `reset.ts` had to alias it on the way in to stay readable.
@@ -299,21 +385,27 @@ export const history = (runId) => [...(bus().streams.get(runId)?.events ?? [])];
299
385
  export const resetEvents = () => {
300
386
  const held = bus();
301
387
  held.streams.clear();
302
- if (held.sweeping)
388
+ if (held.sweeping) {
303
389
  clearTimeout(held.sweeping);
390
+ }
304
391
  held.sweeping = null;
305
392
  // In place, so a watcher still open reads the defaults too and not the caps it began under.
306
393
  Object.assign(held.limits, DEFAULTS);
307
394
  };
308
395
  /**
309
- * Consecutive tokens of one kind are one thing being said, not hundreds of things.
396
+ * A run's events with each stretch of `thinking` or of `output` joined into one block, for a
397
+ * reader that takes a run in snapshots.
310
398
  *
311
- * A client that reads a run in snapshots rather than token by token wants it that way: a
399
+ * @param events - Events in `seq` order, from `history` or collected from `watch`. Not written to.
400
+ * @returns One event per block, every one a copy. A run of `thinking` or of `output` within one
401
+ * step is a single block with the texts joined; anything else is a block to itself.
402
+ *
403
+ * @remarks
404
+ * Consecutive tokens of one kind are one thing being said, not hundreds of things. A client that
405
+ * reads a run in snapshots rather than token by token wants it that way: a
312
406
  * reasoning model spends ten thousand deltas on a paragraph, and a paragraph is what it meant.
313
407
  * Each block carries the `seq` of its last event, so asking for what came after one block
314
408
  * picks up exactly where it left off.
315
- *
316
- * @param events Events in `seq` order, from `history` or collected from `watch`.
317
409
  */
318
410
  export function fold(events) {
319
411
  const blocks = [];
@@ -322,17 +414,20 @@ export function fold(events) {
322
414
  // built ten thousand times to produce it once.
323
415
  let parts = [];
324
416
  const close = () => {
325
- if (!parts.length)
417
+ if (!parts.length) {
326
418
  return;
419
+ }
327
420
  const last = blocks[blocks.length - 1];
328
- if (parts.length > 1)
329
- last.text = parts.join("");
421
+ if (parts.length > 1) {
422
+ last.text = parts.join('');
423
+ }
330
424
  parts = [];
331
425
  };
332
426
  for (const event of events) {
333
427
  const last = blocks[blocks.length - 1];
334
- const mergeable = event.kind === "thinking" || event.kind === "output";
335
- if (last && mergeable && last.kind === event.kind && last.step === event.step) {
428
+ const mergeable = event.kind === RunEventKind.Thinking || event.kind === RunEventKind.Output;
429
+ const continuesLast = last && last.kind === event.kind && last.step === event.step;
430
+ if (mergeable && continuesLast) {
336
431
  last.seq = event.seq;
337
432
  last.at = event.at;
338
433
  parts.push(event.text);
@@ -344,25 +439,58 @@ export function fold(events) {
344
439
  // `fold(history(id))[0].text = ...` rewrite the bus, and every watcher after it read the
345
440
  // rewrite.
346
441
  blocks.push({ ...event });
347
- if (mergeable)
442
+ if (mergeable) {
348
443
  parts.push(event.text);
444
+ }
349
445
  }
350
446
  }
351
447
  close();
352
448
  return blocks;
353
449
  }
450
+ /** How a run ended, as `runMetrics` reads it. */
451
+ export const RunOutcome = {
452
+ /** The run did not fail, and its last turn was not cut off. */
453
+ Answered: 'answered',
454
+ /** The run did not fail, and its last turn is an answer the reply ceiling cut off. */
455
+ Truncated: 'truncated',
456
+ /**
457
+ * `done` said the run was not ok. Whether that was an error, a stop or the tool budget is in
458
+ * the host's own `done` text and not here.
459
+ */
460
+ Failed: 'failed',
461
+ };
462
+ /**
463
+ * How a run that reported `done` ended.
464
+ *
465
+ * @param ok - What the `done` event said. Only `false` is a failure; anything else is read off the turn.
466
+ * @param last - The last turn a `usage` event reported, if any did.
467
+ * @returns `failed` for a `done` that was not ok, `truncated` where the last turn ran into the
468
+ * reply ceiling, and `answered` otherwise.
469
+ */
470
+ function outcomeOf(ok, last) {
471
+ if (ok === false) {
472
+ return RunOutcome.Failed;
473
+ }
474
+ if (last?.finishReason === FinishReason.Length) {
475
+ return RunOutcome.Truncated;
476
+ }
477
+ return RunOutcome.Answered;
478
+ }
354
479
  /**
355
480
  * A run's totals, timings and cache findings, derived from the events it emitted.
356
481
  *
482
+ * @param events - A run's events in `seq` order, from `history` or collected from `watch`. A backlog
483
+ * that has lost its oldest events to the cap sums what it still has.
484
+ * @param [options] - The served window, for how full the run came to it.
485
+ * @returns A summary of the caller's own. The counts are zero for an empty list, and `outcome` is
486
+ * absent for a run that has not reported `done`.
487
+ *
488
+ * @remarks
357
489
  * A sibling of `fold` rather than part of it. `fold` hands back events, and a client renders
358
490
  * what it returns as blocks; a summary is another shape, and folding one in would give every
359
491
  * consumer of `fold` a block it does not know how to draw. Derived from the `usage` reports' own
360
492
  * `turn` rather than the running totals, so a run with several loops in it — a question per loop —
361
493
  * adds up the same as one with a single loop.
362
- *
363
- * @param events A run's events in `seq` order, from `history` or collected from `watch`. A backlog
364
- * that has lost its oldest events to the cap sums what it still has.
365
- * @param options The served window, for how full the run came to it.
366
494
  */
367
495
  export function runMetrics(events, { contextLength } = {}) {
368
496
  const metrics = {
@@ -381,10 +509,10 @@ export function runMetrics(events, { contextLength } = {}) {
381
509
  };
382
510
  /** Adds to a field that is absent until something reports it. */
383
511
  const add = (field, value) => {
384
- if (value === undefined)
512
+ if (value === undefined) {
385
513
  return;
386
- const known = metrics;
387
- known[field] = (known[field] ?? 0) + value;
514
+ }
515
+ metrics[field] = (metrics[field] ?? 0) + value;
388
516
  };
389
517
  let reportedPrompt = 0;
390
518
  let reportedCached = 0;
@@ -393,24 +521,26 @@ export function runMetrics(events, { contextLength } = {}) {
393
521
  const pending = new Map();
394
522
  let last;
395
523
  for (const event of events) {
396
- if (event.kind === "step")
524
+ if (event.kind === RunEventKind.Step) {
397
525
  metrics.steps++;
398
- else if (event.kind === "tool-call") {
399
- if (event.name === LOAD_TOOLS)
526
+ }
527
+ else if (event.kind === RunEventKind.ToolCall) {
528
+ if (event.name === LOAD_TOOLS) {
400
529
  metrics.loadCalls++;
401
- const waiting = pending.get(event.name) ?? [];
402
- waiting.push(event.at);
403
- pending.set(event.name, waiting);
530
+ }
531
+ getOrCreate(pending, event.name, () => []).push(event.at);
404
532
  }
405
- else if (event.kind === "tool-result") {
533
+ else if (event.kind === RunEventKind.ToolResult) {
406
534
  metrics.toolCalls++;
407
- if (event.ok === false)
535
+ if (event.ok === false) {
408
536
  metrics.toolErrors[event.name] = (metrics.toolErrors[event.name] ?? 0) + 1;
537
+ }
409
538
  const called = pending.get(event.name)?.shift();
410
- if (called !== undefined)
411
- add("toolMs", event.at - called);
539
+ if (called !== undefined) {
540
+ add('toolMs', event.at - called);
541
+ }
412
542
  }
413
- else if (event.kind === "usage" && event.usage?.turn) {
543
+ else if (event.kind === RunEventKind.Usage && event.usage?.turn) {
414
544
  const turn = event.usage.turn;
415
545
  last = turn;
416
546
  metrics.turns++;
@@ -419,45 +549,52 @@ export function runMetrics(events, { contextLength } = {}) {
419
549
  metrics.completionTokens += turn.completion;
420
550
  metrics.cachedTokens += turn.cached;
421
551
  if (turn.uncached !== undefined) {
422
- add("uncachedTokens", turn.uncached);
552
+ add('uncachedTokens', turn.uncached);
423
553
  reportedPrompt += turn.prompt;
424
554
  reportedCached += turn.cached;
425
555
  }
426
- add("reasoningTokens", turn.reasoningTokens);
427
- add("promptMs", turn.promptMs);
428
- add("predictedMs", turn.predictedMs);
429
- add("draftTotal", turn.draftTotal);
430
- add("draftAccepted", turn.draftAccepted);
556
+ add('reasoningTokens', turn.reasoningTokens);
557
+ add('promptMs', turn.promptMs);
558
+ add('predictedMs', turn.predictedMs);
559
+ add('draftTotal', turn.draftTotal);
560
+ add('draftAccepted', turn.draftAccepted);
431
561
  if (turn.cacheBroken) {
432
562
  metrics.cacheBreaks++;
433
- const reason = turn.cacheBreakReason ?? "none-known";
563
+ const reason = turn.cacheBreakReason ?? CacheBreakReason.NoneKnown;
434
564
  metrics.cacheBreakReasons[reason] = (metrics.cacheBreakReasons[reason] ?? 0) + 1;
435
565
  }
436
- if (turn.finishReason === "length")
566
+ if (turn.finishReason === FinishReason.Length) {
437
567
  metrics.truncatedTurns++;
438
- if (turn.wallMs !== undefined)
568
+ }
569
+ if (turn.wallMs !== undefined) {
439
570
  metrics.slowestTurnMs = Math.max(metrics.slowestTurnMs ?? 0, turn.wallMs);
571
+ }
440
572
  if (turn.firstTokenMs !== undefined) {
441
- add("firstTokenMs", turn.firstTokenMs);
573
+ add('firstTokenMs', turn.firstTokenMs);
442
574
  firstTokens++;
443
575
  }
444
- if (turn.prompt > 0)
576
+ if (turn.prompt > 0) {
445
577
  metrics.largestPrompt = Math.max(metrics.largestPrompt ?? 0, turn.prompt);
578
+ }
446
579
  }
447
- else if (event.kind === "done") {
448
- metrics.outcome =
449
- event.ok === false ? "failed" : last?.finishReason === "length" ? "truncated" : "answered";
580
+ else if (event.kind === RunEventKind.Done) {
581
+ metrics.outcome = outcomeOf(event.ok, last);
450
582
  }
451
583
  }
452
- if (events.length > 1)
584
+ if (events.length > 1) {
453
585
  metrics.wallMs = events[events.length - 1].at - events[0].at;
454
- if (reportedPrompt > 0)
586
+ }
587
+ if (reportedPrompt > 0) {
455
588
  metrics.cacheHitRatio = reportedCached / reportedPrompt;
456
- if (metrics.firstTokenMs !== undefined)
589
+ }
590
+ if (metrics.firstTokenMs !== undefined) {
457
591
  metrics.firstTokenMs /= firstTokens;
458
- if (metrics.draftTotal && metrics.draftAccepted !== undefined)
592
+ }
593
+ if (metrics.draftTotal && metrics.draftAccepted !== undefined) {
459
594
  metrics.draftAcceptance = metrics.draftAccepted / metrics.draftTotal;
460
- if (metrics.largestPrompt !== undefined && contextLength && contextLength > 0)
595
+ }
596
+ if (metrics.largestPrompt !== undefined && contextLength && contextLength > 0) {
461
597
  metrics.largestPromptShare = metrics.largestPrompt / contextLength;
598
+ }
462
599
  return metrics;
463
600
  }