@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.
- package/README.md +14 -9
- package/dist/agent-loop.d.ts +84 -204
- package/dist/agent-loop.js +416 -643
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -22
- package/dist/capabilities.d.ts +140 -35
- package/dist/capabilities.js +345 -152
- package/dist/client.d.ts +104 -35
- package/dist/client.js +210 -78
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +163 -93
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +25 -17
- package/dist/continuation.js +70 -47
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +108 -58
- package/dist/events.js +265 -128
- package/dist/guards.d.ts +53 -0
- package/dist/guards.js +55 -0
- package/dist/hook-events.d.ts +38 -0
- package/dist/hook-events.js +36 -0
- package/dist/hooks.d.ts +105 -83
- package/dist/hooks.js +224 -139
- package/dist/index.d.ts +30 -25
- package/dist/index.js +29 -23
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +40 -143
- package/dist/retry.js +79 -285
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +44 -16
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +51 -11
- package/dist/schema-compat.js +375 -193
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +127 -39
- package/dist/side-task.js +189 -100
- package/dist/snapshot.d.ts +12 -7
- package/dist/snapshot.js +87 -65
- package/dist/spec.d.ts +66 -36
- package/dist/spec.js +514 -265
- package/dist/stream.d.ts +73 -16
- package/dist/stream.js +228 -98
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +96 -46
- package/dist/tokens.d.ts +171 -6
- package/dist/tokens.js +326 -6
- package/dist/tool-calls.d.ts +89 -15
- package/dist/tool-calls.js +205 -85
- package/dist/tool-loading.d.ts +109 -187
- package/dist/tool-loading.js +286 -372
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +87 -43
- package/package.json +4 -4
package/dist/events.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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:
|
|
8
|
-
retainUnendedMs:
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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:
|
|
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 ===
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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(
|
|
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 ===
|
|
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(
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 ===
|
|
335
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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 ===
|
|
524
|
+
if (event.kind === RunEventKind.Step) {
|
|
397
525
|
metrics.steps++;
|
|
398
|
-
|
|
399
|
-
|
|
526
|
+
}
|
|
527
|
+
else if (event.kind === RunEventKind.ToolCall) {
|
|
528
|
+
if (event.name === LOAD_TOOLS) {
|
|
400
529
|
metrics.loadCalls++;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
pending.set(event.name, waiting);
|
|
530
|
+
}
|
|
531
|
+
getOrCreate(pending, event.name, () => []).push(event.at);
|
|
404
532
|
}
|
|
405
|
-
else if (event.kind ===
|
|
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(
|
|
539
|
+
if (called !== undefined) {
|
|
540
|
+
add('toolMs', event.at - called);
|
|
541
|
+
}
|
|
412
542
|
}
|
|
413
|
-
else if (event.kind ===
|
|
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(
|
|
552
|
+
add('uncachedTokens', turn.uncached);
|
|
423
553
|
reportedPrompt += turn.prompt;
|
|
424
554
|
reportedCached += turn.cached;
|
|
425
555
|
}
|
|
426
|
-
add(
|
|
427
|
-
add(
|
|
428
|
-
add(
|
|
429
|
-
add(
|
|
430
|
-
add(
|
|
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 ??
|
|
563
|
+
const reason = turn.cacheBreakReason ?? CacheBreakReason.NoneKnown;
|
|
434
564
|
metrics.cacheBreakReasons[reason] = (metrics.cacheBreakReasons[reason] ?? 0) + 1;
|
|
435
565
|
}
|
|
436
|
-
if (turn.finishReason ===
|
|
566
|
+
if (turn.finishReason === FinishReason.Length) {
|
|
437
567
|
metrics.truncatedTurns++;
|
|
438
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
|
|
586
|
+
}
|
|
587
|
+
if (reportedPrompt > 0) {
|
|
455
588
|
metrics.cacheHitRatio = reportedCached / reportedPrompt;
|
|
456
|
-
|
|
589
|
+
}
|
|
590
|
+
if (metrics.firstTokenMs !== undefined) {
|
|
457
591
|
metrics.firstTokenMs /= firstTokens;
|
|
458
|
-
|
|
592
|
+
}
|
|
593
|
+
if (metrics.draftTotal && metrics.draftAccepted !== undefined) {
|
|
459
594
|
metrics.draftAcceptance = metrics.draftAccepted / metrics.draftTotal;
|
|
460
|
-
|
|
595
|
+
}
|
|
596
|
+
if (metrics.largestPrompt !== undefined && contextLength && contextLength > 0) {
|
|
461
597
|
metrics.largestPromptShare = metrics.largestPrompt / contextLength;
|
|
598
|
+
}
|
|
462
599
|
return metrics;
|
|
463
600
|
}
|