@cubicecho/agent-core 2.11.0 → 2.12.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.
@@ -62,8 +62,14 @@ export interface CompactionOptions {
62
62
  compactAt?: number;
63
63
  /** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
64
64
  keepRatio?: number;
65
- /** One message's tokens. `messageTokens` by default. */
65
+ /** One message's tokens. `messageTokens` by default, divided by `charsPerToken`. */
66
66
  estimate?: (message: Message) => number;
67
+ /**
68
+ * The divisor the default `estimate` uses — `charsPerTokenFor` the model, for a transcript
69
+ * weighed the way `runTurn` sizes its requests. `CHARS_PER_TOKEN` when absent; ignored beside
70
+ * an `estimate` of the caller's own.
71
+ */
72
+ charsPerToken?: number;
67
73
  }
68
74
  /** Where to cut, as `compactTranscript` takes it. */
69
75
  export interface CompactionPlan {
@@ -89,7 +95,7 @@ export interface CompactionPlan {
89
95
  * @param messages The transcript, system prompts included if the caller keeps them in it.
90
96
  * @param options The window, what is in use, and the ratios. See `CompactionOptions`.
91
97
  */
92
- export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, estimate, }: CompactionOptions): CompactionPlan | undefined;
98
+ export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, }: CompactionOptions): CompactionPlan | undefined;
93
99
  /**
94
100
  * What the summariser is handed for a plan: the earlier summary if there was one, then each
95
101
  * message as its role and at most 4000 characters of its text.
@@ -113,21 +119,33 @@ export declare const summariser: (config: Endpoint, model: string, { system, max
113
119
  *
114
120
  * `beforeCompact` is told what is being folded while the summary is written, beside it rather
115
121
  * than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
116
- * `notify` never rejects. A hook cannot veto the compaction: `HookOutcome` has no way to say so,
117
- * and a run over its window has no better option anyway. An empty summary folds nothing, and the
118
- * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
122
+ * `notify` never rejects. A host that wants its hooks able to stop a compaction sets
123
+ * `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
124
+ * `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
125
+ * compaction ignores a veto and runs the hooks beside the summary as before, because a run already
126
+ * past its window has no better option — a veto there only trades the summary for a
127
+ * `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
128
+ * comment on when to run it.
119
129
  *
120
130
  * @param messages The transcript the plan was made for. Not written to.
121
131
  * @param plan What `planCompaction` returned for it.
122
- * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
132
+ * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
133
+ * when a hook vetoes.
123
134
  * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
124
- * indexes are the plan's.
135
+ * indexes are the plan's. `honourVeto` waits for the hooks and lets one stop the compaction; off
136
+ * by default, which adds no latency. `forced` says the window is already exceeded — the caller
137
+ * caught a `ContextOverflow`, or is compacting to make a refused request fit — and overrides
138
+ * `honourVeto`.
139
+ * @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
140
+ * new array.
125
141
  */
126
- export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, { hooks, }?: {
142
+ export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, { hooks, forced, }?: {
127
143
  hooks?: {
128
144
  run: HookRunner;
129
145
  context: HookContext;
130
146
  onNote?: (note: HookNote) => void;
147
+ honourVeto?: boolean;
131
148
  };
149
+ forced?: boolean;
132
150
  }): Promise<Message[]>;
133
151
  export {};
@@ -1,4 +1,4 @@
1
- import { notify, turnMessages } from "./hooks.js";
1
+ import { consult, notify, turnMessages, } from "./hooks.js";
2
2
  import { messageTokens } from "./retry.js";
3
3
  import { ask } from "./side-task.js";
4
4
  /** The fraction of the window in use before a summary is worth its own round trip. */
@@ -83,7 +83,7 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
83
83
  * @param messages The transcript, system prompts included if the caller keeps them in it.
84
84
  * @param options The window, what is in use, and the ratios. See `CompactionOptions`.
85
85
  */
86
- export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, estimate = messageTokens, }) {
86
+ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), }) {
87
87
  if (!(limit > 0))
88
88
  return undefined;
89
89
  const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
@@ -143,26 +143,45 @@ export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens =
143
143
  *
144
144
  * `beforeCompact` is told what is being folded while the summary is written, beside it rather
145
145
  * than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
146
- * `notify` never rejects. A hook cannot veto the compaction: `HookOutcome` has no way to say so,
147
- * and a run over its window has no better option anyway. An empty summary folds nothing, and the
148
- * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
146
+ * `notify` never rejects. A host that wants its hooks able to stop a compaction sets
147
+ * `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
148
+ * `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
149
+ * compaction ignores a veto and runs the hooks beside the summary as before, because a run already
150
+ * past its window has no better option — a veto there only trades the summary for a
151
+ * `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
152
+ * comment on when to run it.
149
153
  *
150
154
  * @param messages The transcript the plan was made for. Not written to.
151
155
  * @param plan What `planCompaction` returned for it.
152
- * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
156
+ * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
157
+ * when a hook vetoes.
153
158
  * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
154
- * indexes are the plan's.
159
+ * indexes are the plan's. `honourVeto` waits for the hooks and lets one stop the compaction; off
160
+ * by default, which adds no latency. `forced` says the window is already exceeded — the caller
161
+ * caught a `ContextOverflow`, or is compacting to make a refused request fit — and overrides
162
+ * `honourVeto`.
163
+ * @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
164
+ * new array.
155
165
  */
156
- export async function compactTranscript(messages, plan, summarise, { hooks, } = {}) {
157
- const [summary] = await Promise.all([
158
- summarise(summaryInput(plan)),
159
- hooks &&
160
- notify(hooks.run, "beforeCompact", {
161
- ...hooks.context,
162
- compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
163
- range: { from: plan.from, through: plan.cut },
164
- }, hooks.onNote),
165
- ]);
166
+ export async function compactTranscript(messages, plan, summarise, { hooks, forced = false, } = {}) {
167
+ const context = hooks && {
168
+ ...hooks.context,
169
+ compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
170
+ range: { from: plan.from, through: plan.cut },
171
+ };
172
+ let summary;
173
+ if (hooks && context && hooks.honourVeto && !forced) {
174
+ const { vetoed } = await consult(hooks.run, "beforeCompact", context, hooks.onNote);
175
+ if (vetoed)
176
+ return messages;
177
+ summary = await summarise(summaryInput(plan));
178
+ }
179
+ else {
180
+ [summary] = await Promise.all([
181
+ summarise(summaryInput(plan)),
182
+ hooks && context && notify(hooks.run, "beforeCompact", context, hooks.onNote),
183
+ ]);
184
+ }
166
185
  if (!summary.trim())
167
186
  return messages;
168
187
  return [
@@ -0,0 +1,59 @@
1
+ import OpenAI from "openai";
2
+ import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
+ import { type RunTurnOptions } from "./run-turn.ts";
4
+ import type { Turn } from "./stream.ts";
5
+ /**
6
+ * Picking up an answer the token ceiling cut off, instead of keeping half of it.
7
+ *
8
+ * A turn that stops at `maxTokens` mid-answer comes back looking finished, and the half answer
9
+ * becomes the answer. A server that renders a trailing assistant message as a prefill lets the
10
+ * model carry on from the last token as if nothing had happened, which costs the rest of the
11
+ * reply and a prefill the cache mostly already holds.
12
+ */
13
+ /** What `continueTurn` takes besides what `runTurn` does. */
14
+ export interface ContinueTurnOptions extends RunTurnOptions {
15
+ /**
16
+ * How many more requests one answer may be given, 1 unless given; zero continues nothing.
17
+ * The cap is what stops a model that never reaches a stop token from looping on the ceiling.
18
+ */
19
+ maxContinuations?: number;
20
+ }
21
+ /**
22
+ * Whether a turn is one a continuation can finish: cut off at the ceiling, with an answer begun
23
+ * and no tool call in it.
24
+ *
25
+ * An answer not begun is a turn cut off in its scratchpad, and prefilling a half-closed fence is
26
+ * the template's business rather than something this can do the same way everywhere — llama.cpp
27
+ * refuses a prefill outright on a template with thinking enabled. A tool call is excluded because
28
+ * its arguments are what was cut, and `parseToolArguments` already reports that truncation.
29
+ *
30
+ * @param turn The turn as it came back.
31
+ */
32
+ export declare const isContinuable: (turn: Turn) => boolean;
33
+ /**
34
+ * Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
35
+ * so far as a trailing assistant message, and joins the pieces into one turn.
36
+ *
37
+ * Only a turn `isContinuable` accepts is continued; any other comes back as it was. Content and
38
+ * reasoning are joined in order, the tool calls a continuation makes are kept, and usage is summed
39
+ * across the requests with `continuations` counting them. The continuation is read as starting in
40
+ * the answer, whatever `startInReasoning` says: a template that opens a fence for a fresh reply
41
+ * does not open one for a prefill. Its tokens reach `onOutput` as they arrive, so a watcher sees
42
+ * one answer carry on rather than two.
43
+ *
44
+ * Whether the server continues at all is latched per model as `assistantPrefill`. A refusal of the
45
+ * request latches it off, and so does a continuation that begins the answer again, which is how a
46
+ * server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
47
+ * that check is only as good as a restart being word for word. Either way the answer so far is
48
+ * kept, with a notice. So is it when the continuation fails any other way, since the tokens
49
+ * already in hand are worth more than the error; only a stop is thrown.
50
+ *
51
+ * @param client The pooled client for this endpoint.
52
+ * @param supports What the endpoint has already refused.
53
+ * @param request Builds the body the cut-off turn was sent, exactly as `runTurn` was given it. The
54
+ * prefill is appended to what it builds.
55
+ * @param turn The turn that came back cut off.
56
+ * @param options `runTurn`'s options, with `model` needed for the latch — without one nothing is
57
+ * latched and each continuation finds out again — and the cap on continuations.
58
+ */
59
+ export declare function continueTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
@@ -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
+ }