@matatbread/matbot-core 0.4.1 → 0.4.3
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/package.json +2 -2
- package/src/index.ts +3 -1
- package/src/registry.ts +3 -9
- package/src/runner.ts +62 -16
- package/src/session-runner.ts +79 -4
- package/src/session.ts +1 -3
- package/src/storage-base/query/execute.ts +4 -1
- package/src/usage.ts +63 -14
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.3",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/index.ts
CHANGED
|
@@ -29,10 +29,12 @@ export {
|
|
|
29
29
|
createBroadcaster, subscribable, createNotifier, scopedNotifier,
|
|
30
30
|
installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
|
|
31
31
|
contextSwitch, onContextQuiesce, flushIfQuiescent,
|
|
32
|
-
installUsageCarrier, createSerialUsageCarrier, recordUsage,
|
|
32
|
+
installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
|
|
33
|
+
currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
|
|
33
34
|
} from '@matatbread/matbot-plugin-api/host';
|
|
34
35
|
export type {
|
|
35
36
|
Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
|
|
37
|
+
UsageScope,
|
|
36
38
|
} from '@matatbread/matbot-plugin-api/host';
|
|
37
39
|
export * from './session.js';
|
|
38
40
|
export * from './usage.js';
|
package/src/registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig } from './types.js';
|
|
1
|
+
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig, FailedPlugin } from './types.js';
|
|
2
2
|
import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
|
|
4
4
|
import type {
|
|
@@ -11,14 +11,8 @@ import type { SettingsDoc } from './settings.js';
|
|
|
11
11
|
|
|
12
12
|
// ── Internal state ────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* module that is not plugin-shaped, or a setup() throw. Recorded (not discarded) so the failure is
|
|
17
|
-
* *graceful but not silent*: the `plugin` tool and web UI can show which configured plugins failed
|
|
18
|
-
* and why, instead of the failure vanishing into a boot-time console line. `error` is pre-stringified
|
|
19
|
-
* (sidesteps the `unknown` catch-var); `name` is present only when known before the failure.
|
|
20
|
-
*/
|
|
21
|
-
export type FailedPlugin = { specifier: string; name?: string; error: string };
|
|
14
|
+
// `FailedPlugin` now lives in plugin-api: `plugin list` reports it, so it is part of that tool's
|
|
15
|
+
// contract, and the contract has to be declarable from a package this one can reach.
|
|
22
16
|
|
|
23
17
|
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
24
18
|
const state = {
|
package/src/runner.ts
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Session, Message, MessageContent, ModelContent, Usage, ProviderMeta, TruncatedToolResult,
|
|
2
|
+
Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
|
|
3
3
|
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
|
-
import {
|
|
8
|
+
import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
|
|
9
9
|
import { HookRegistry } from './hooks.js';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
11
|
import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
12
12
|
import { addUsage } from './usage.js';
|
|
13
13
|
|
|
14
|
+
// Establish a usage call site across an async iteration. Wrapping the *loop* would not work: this
|
|
15
|
+
// runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
|
|
16
|
+
// context, losing any scope entered around the loop body. Scoping each `next()` instead means the
|
|
17
|
+
// tool's body — and anything it kicks off while running — always resumes under its own site.
|
|
18
|
+
function siteScoped<T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> {
|
|
19
|
+
return {
|
|
20
|
+
[Symbol.asyncIterator]: () => {
|
|
21
|
+
const it = src[Symbol.asyncIterator]();
|
|
22
|
+
return {
|
|
23
|
+
next: () => withUsageSite(site, () => it.next()),
|
|
24
|
+
...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
|
|
25
|
+
...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
15
32
|
// the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
|
|
16
33
|
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
@@ -239,6 +256,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
239
256
|
const assistantParts: MessageContent[] = [];
|
|
240
257
|
let textAcc = '';
|
|
241
258
|
let turnUsage: Usage | undefined;
|
|
259
|
+
// The round's bracket — set where the provider call is actually issued, below, so it measures the
|
|
260
|
+
// call rather than the screen/contribute work that precedes it.
|
|
261
|
+
let roundStartedAt = Date.now();
|
|
262
|
+
// Book the round's own spend as an entry the moment the stream settles — including when the response
|
|
263
|
+
// is then thrown away (an in-situ restart) or the call died part-way. The tokens were billed either
|
|
264
|
+
// way; tying the record to whether the *answer* survived is what made a restarted round free. Clears
|
|
265
|
+
// as it books, so no exit path can double-count it.
|
|
266
|
+
const bookRound = (): void => {
|
|
267
|
+
const u = turnUsage;
|
|
268
|
+
if (u === undefined) return;
|
|
269
|
+
turnUsage = undefined;
|
|
270
|
+
withUsageSite({ kind: 'round', round }, () => recordUsage(config.provider, u, {
|
|
271
|
+
startedAt: new Date(roundStartedAt).toISOString(),
|
|
272
|
+
durationMs: Date.now() - roundStartedAt,
|
|
273
|
+
}));
|
|
274
|
+
};
|
|
242
275
|
let truncation: { reason: 'max-tokens' | 'stream-end'; raw?: string } | undefined;
|
|
243
276
|
// Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
|
|
244
277
|
let restart = false;
|
|
@@ -264,14 +297,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
264
297
|
// Tools advertised for THIS call. A presenter (optional) may present a subset of the snapshot and
|
|
265
298
|
// grow it across iterations as the model discovers tools; absent ⇒ the whole snapshot. Withheld
|
|
266
299
|
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
300
|
+
// Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
|
|
301
|
+
// that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
|
|
267
302
|
const advertised = opts.toolPresenter
|
|
268
|
-
? await
|
|
303
|
+
? await withUsageSite({ kind: 'round', round },
|
|
304
|
+
() => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
|
|
269
305
|
: [...tools.values()];
|
|
270
306
|
// Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
|
|
271
307
|
// to cancel the in-flight request and stop backend generation, without aborting the whole turn.
|
|
272
308
|
const callAc = new AbortController();
|
|
273
309
|
const callSignal = AbortSignal.any([signal, callAc.signal]);
|
|
274
310
|
try {
|
|
311
|
+
roundStartedAt = Date.now();
|
|
275
312
|
for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
|
|
276
313
|
// A raced verdict firing mid-generation changes the premise: discard this doomed response and
|
|
277
314
|
// restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
|
|
@@ -308,9 +345,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
308
345
|
case 'usage':
|
|
309
346
|
turnUsage = addUsage(turnUsage, ev);
|
|
310
347
|
yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
|
|
311
|
-
...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
|
|
312
348
|
...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
|
|
313
|
-
...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {})
|
|
349
|
+
...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {}),
|
|
350
|
+
...(ev.reported !== undefined ? { reported: ev.reported } : {}) };
|
|
314
351
|
break;
|
|
315
352
|
case 'done':
|
|
316
353
|
break;
|
|
@@ -322,12 +359,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
322
359
|
if (restart || (callAc.signal.aborted && !signal.aborted)) {
|
|
323
360
|
restart = true;
|
|
324
361
|
} else if (signal.aborted) {
|
|
362
|
+
bookRound();
|
|
325
363
|
// Save whatever the LLM streamed before the abort hit.
|
|
326
364
|
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
327
365
|
if (assistantParts.length > 0) {
|
|
328
366
|
session = appendMessage(session, createMessage({
|
|
329
367
|
role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
|
|
330
|
-
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
331
368
|
}));
|
|
332
369
|
}
|
|
333
370
|
yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
|
|
@@ -343,10 +380,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
343
380
|
// belong to the call that failed, and the model never finished the thought. The `error` event
|
|
344
381
|
// carries no session (a failure is not a transcript), so this yields the terminal itself rather
|
|
345
382
|
// than the session — but it commits first, exactly like every other exit.
|
|
383
|
+
bookRound();
|
|
346
384
|
yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
|
|
347
385
|
return;
|
|
348
386
|
}
|
|
349
387
|
}
|
|
388
|
+
bookRound();
|
|
350
389
|
|
|
351
390
|
// In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
|
|
352
391
|
// raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
|
|
@@ -370,7 +409,6 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
370
409
|
content: assistantParts,
|
|
371
410
|
traceId,
|
|
372
411
|
providerName: config.provider,
|
|
373
|
-
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
374
412
|
});
|
|
375
413
|
session = appendMessage(session, assistantMsg);
|
|
376
414
|
}
|
|
@@ -487,9 +525,10 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
487
525
|
let result: unknown;
|
|
488
526
|
let isError = false;
|
|
489
527
|
// Token accounting for completions this tool runs (via singleTurn/complete) accrues into the
|
|
490
|
-
// ambient turn
|
|
491
|
-
|
|
492
|
-
|
|
528
|
+
// ambient turn scope, stamped with the site established below — never attached to this message.
|
|
529
|
+
// A tool message is popped wholesale by a retract-and-rerun, which would take its accounting
|
|
530
|
+
// somewhere no reduction over the session can reach; the entry lives on the turn head instead.
|
|
531
|
+
const site: UsageSite = { kind: 'tool', callId: tc.id, tool: tc.name };
|
|
493
532
|
const startedAt = Date.now();
|
|
494
533
|
|
|
495
534
|
const toolCtx: ToolContext = {
|
|
@@ -503,7 +542,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
503
542
|
};
|
|
504
543
|
|
|
505
544
|
try {
|
|
506
|
-
for await (const toolEv of tool.executor.execute(tc.input, toolCtx)) {
|
|
545
|
+
for await (const toolEv of siteScoped(site, tool.executor.execute(tc.input, toolCtx))) {
|
|
507
546
|
switch (toolEv.type) {
|
|
508
547
|
case 'stdout': yield { type: 'tool:stdout', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
509
548
|
case 'stderr': yield { type: 'tool:stderr', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
@@ -534,16 +573,23 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
534
573
|
|
|
535
574
|
// toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
|
|
536
575
|
// or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
|
|
576
|
+
const durationMs = Date.now() - startedAt;
|
|
577
|
+
|
|
578
|
+
// The bracket, recorded for EVERY tool call — not just the ones that spent tokens. Most tools
|
|
579
|
+
// (bash, http, workspace) run no completion at all, so hanging duration off an accounting record
|
|
580
|
+
// would capture it precisely for the tools that happen to call an LLM and lose it for the rest.
|
|
581
|
+
// Previously this number was computed, handed to the `toolresult` hook, and then discarded, so a
|
|
582
|
+
// consumer had to re-derive it — less accurately — from event arrival times.
|
|
583
|
+
recordSpan(site, new Date(startedAt).toISOString(), durationMs);
|
|
584
|
+
|
|
537
585
|
result = await hookReg.runToolResult({
|
|
538
586
|
session, config, signal,
|
|
539
587
|
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
540
|
-
tool, result, isError, durationMs
|
|
588
|
+
tool, result, isError, durationMs,
|
|
541
589
|
});
|
|
542
590
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
...(callUsage.length > 0 ? { usage: callUsage } : {}) });
|
|
546
|
-
yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
|
|
591
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result, isError });
|
|
592
|
+
yield { type: 'tool:end', callId: tc.id, result, isError, durationMs, traceId };
|
|
547
593
|
}
|
|
548
594
|
|
|
549
595
|
// Add tool results message, then loop for the next provider call.
|
package/src/session-runner.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
|
|
3
3
|
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
|
|
4
4
|
ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
|
|
5
|
-
SteeringPolicy, SteeringDecision,
|
|
5
|
+
SteeringPolicy, SteeringDecision, TurnEntry,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
@@ -103,6 +103,11 @@ interface SessionState {
|
|
|
103
103
|
// Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
|
|
104
104
|
// turn boundary — committed history comes from the store, not from here.
|
|
105
105
|
replay: PipelineEvent[];
|
|
106
|
+
// Accounting entries awaiting a flush, held BY REFERENCE to each turn's usage scope. A completion
|
|
107
|
+
// can be recorded after its turn commits — a detached trigger classifier, a `followup` hook — and it
|
|
108
|
+
// appends to the very array captured here, so a late arrival needs no coordination: whatever is in
|
|
109
|
+
// these arrays at the flush is what gets written, and each entry says which turn caused it.
|
|
110
|
+
pendingUsage: TurnEntry[][];
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
interface Sink {
|
|
@@ -163,7 +168,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
163
168
|
const stateFor = (id: string): SessionState => {
|
|
164
169
|
let s = states.get(id);
|
|
165
170
|
if (s === undefined) {
|
|
166
|
-
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
|
|
171
|
+
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [] };
|
|
167
172
|
states.set(id, s);
|
|
168
173
|
}
|
|
169
174
|
return s;
|
|
@@ -190,6 +195,63 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
190
195
|
// How many submissions sit ahead of the item at queue index `i` (the running turn counts as one).
|
|
191
196
|
const aheadOf = (s: SessionState, i: number): number => (s.running ? 1 : 0) + i;
|
|
192
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Write whatever accounting has accrued onto the turn heads that caused it, then clear it.
|
|
200
|
+
*
|
|
201
|
+
* Entries are drained by SPLICING each pending array, so an entry appended after this point (a
|
|
202
|
+
* classifier still in flight) accumulates afresh and lands on the next flush rather than being
|
|
203
|
+
* written twice. That is the whole concurrency story: no marks, no coordination, and eventual
|
|
204
|
+
* delivery — which is all a log needs, since each entry names the turn that caused it.
|
|
205
|
+
*
|
|
206
|
+
* Anchored on the turn's head (its user message) because that is the message a retract-and-rerun
|
|
207
|
+
* keeps; assistant and tool messages are popped into a retraction marker where no reduction over
|
|
208
|
+
* `session.messages` would find them again. An entry whose turn head has since gone (a session
|
|
209
|
+
* edit, a compaction) is dropped rather than orphaned onto an unrelated message.
|
|
210
|
+
*/
|
|
211
|
+
const flushUsage = async (id: string, s: SessionState): Promise<void> => {
|
|
212
|
+
// Splice each array in place and KEEP the references. Dropping a drained array is the obvious
|
|
213
|
+
// tidy-up and it silently loses exactly the case this exists for: a classifier that settles after
|
|
214
|
+
// the flush appends to the array its scope captured, so if the pump has stopped holding it, the
|
|
215
|
+
// write goes nowhere observable. They are left registered for the life of the session state, at a
|
|
216
|
+
// cost of one empty array per turn.
|
|
217
|
+
//
|
|
218
|
+
// The limit of that: `maybeCleanup` disposes the state once the session is quiet and unsubscribed,
|
|
219
|
+
// so a completion still in flight at THAT point is lost. This is the carrier's stated best-effort
|
|
220
|
+
// contract rather than a hole to plug — nothing can know what is still pending — and it is bounded
|
|
221
|
+
// in practice, since the two channels that settle late (a `followup` hook, a user-phase classifier
|
|
222
|
+
// the runner polls) are both awaited inside the pump iteration this scope wraps.
|
|
223
|
+
const drained = s.pendingUsage.flatMap(entries => entries.splice(0));
|
|
224
|
+
if (drained.length === 0) return;
|
|
225
|
+
|
|
226
|
+
const session = await deps.store.get(id);
|
|
227
|
+
if (!session) return;
|
|
228
|
+
|
|
229
|
+
// Anchor on the entry's own turn head when it has one, and on its ROOT's otherwise. A
|
|
230
|
+
// retract-and-rerun re-runs an existing user turn under a fresh traceId and introduces no user
|
|
231
|
+
// message, so its entries have no head of their own — and the root is the honest home anyway, since
|
|
232
|
+
// a retry's cost belongs to the turn being retried. Without this the redo's spend was dropped
|
|
233
|
+
// outright, which is the very under-report anchoring on the turn head exists to prevent.
|
|
234
|
+
const heads = new Set(session.messages.filter(m => m.role === 'user').map(m => m.traceId));
|
|
235
|
+
const byTrace = new Map<string, TurnEntry[]>();
|
|
236
|
+
for (const e of drained) {
|
|
237
|
+
const anchor = e.traceId !== undefined && heads.has(e.traceId) ? e.traceId : e.rootTraceId;
|
|
238
|
+
if (anchor === undefined || !heads.has(anchor)) continue;
|
|
239
|
+
const list = byTrace.get(anchor);
|
|
240
|
+
if (list) list.push(e); else byTrace.set(anchor, [e]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let changed = false;
|
|
244
|
+
const messages = session.messages.map(m => {
|
|
245
|
+
const add = m.role === 'user' ? byTrace.get(m.traceId) : undefined;
|
|
246
|
+
if (add === undefined) return m;
|
|
247
|
+
byTrace.delete(m.traceId);
|
|
248
|
+
changed = true;
|
|
249
|
+
return { ...m, activity: [...(m.activity ?? []), ...add] };
|
|
250
|
+
});
|
|
251
|
+
if (!changed) return;
|
|
252
|
+
await deps.store.set(id, { ...session, messages });
|
|
253
|
+
};
|
|
254
|
+
|
|
193
255
|
const pump = async (id: string, s: SessionState): Promise<void> => {
|
|
194
256
|
if (s.running) return;
|
|
195
257
|
s.running = true;
|
|
@@ -298,7 +360,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
298
360
|
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
299
361
|
// signal-driven aborts and not the policy ones.
|
|
300
362
|
let terminal: PipelineEvent['type'] | undefined;
|
|
301
|
-
|
|
363
|
+
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
364
|
+
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
365
|
+
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
366
|
+
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
367
|
+
// in something already tracked and needs no coordination of its own.
|
|
368
|
+
await withUsageScope(async usage => {
|
|
369
|
+
s.pendingUsage.push(usage.entries);
|
|
370
|
+
await contextSwitch(head.principal, async () => {
|
|
302
371
|
for await (const ev of runSession({
|
|
303
372
|
session,
|
|
304
373
|
config: { provider: head.provider, traceId: head.traceId },
|
|
@@ -323,7 +392,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
323
392
|
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
324
393
|
emit(s, ev);
|
|
325
394
|
}
|
|
326
|
-
})
|
|
395
|
+
});
|
|
327
396
|
|
|
328
397
|
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
329
398
|
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
@@ -439,6 +508,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
439
508
|
}
|
|
440
509
|
}
|
|
441
510
|
}
|
|
511
|
+
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
442
512
|
} catch (e) {
|
|
443
513
|
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
444
514
|
} finally {
|
|
@@ -449,6 +519,11 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
449
519
|
s.running = false;
|
|
450
520
|
s.runningTraceId = undefined;
|
|
451
521
|
s.replay = [];
|
|
522
|
+
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
523
|
+
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
524
|
+
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
525
|
+
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
526
|
+
await flushUsage(id, s);
|
|
452
527
|
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
453
528
|
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
454
529
|
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Session, Message, MessageContent, MessageRole
|
|
1
|
+
import type { Session, Message, MessageContent, MessageRole } from './types.js';
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
4
|
title?: string;
|
|
@@ -37,7 +37,6 @@ export function createMessage(opts: {
|
|
|
37
37
|
content: MessageContent[];
|
|
38
38
|
traceId: string;
|
|
39
39
|
providerName?: string;
|
|
40
|
-
usage?: Usage;
|
|
41
40
|
metadata?: Record<string, unknown>;
|
|
42
41
|
}): Message {
|
|
43
42
|
return {
|
|
@@ -47,7 +46,6 @@ export function createMessage(opts: {
|
|
|
47
46
|
createdAt: new Date().toISOString(),
|
|
48
47
|
traceId: opts.traceId,
|
|
49
48
|
...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
|
|
50
|
-
...(opts.usage !== undefined ? { usage: opts.usage } : {}),
|
|
51
49
|
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
52
50
|
};
|
|
53
51
|
}
|
|
@@ -47,9 +47,12 @@ export function executeQuery<T extends { id: string; version: string }>(docs: T[
|
|
|
47
47
|
const slice = ordered.slice(page.offset, end);
|
|
48
48
|
const next = page.offset + slice.length;
|
|
49
49
|
|
|
50
|
+
// `limit: 0` is the count form: the filter runs, `total` answers, no document is materialised. It
|
|
51
|
+
// gets no cursor — a zero-length page never advances `offset`, so one would page forever on the
|
|
52
|
+
// same empty slice.
|
|
50
53
|
return {
|
|
51
54
|
items: slice,
|
|
52
55
|
total,
|
|
53
|
-
...(next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
|
|
56
|
+
...(page.limit !== 0 && next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
|
|
54
57
|
};
|
|
55
58
|
}
|
package/src/usage.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { Message, Usage } from './types.js';
|
|
1
|
+
import type { Message, TurnEntry, Usage, UsageRecord } from './types.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Fold one usage tally into a running total; optional fields are summed only when either side has them.
|
|
5
|
+
*
|
|
6
|
+
* `reported` sums **numeric values key-wise** and drops the rest: a total `service_tier` is meaningless
|
|
7
|
+
* and a summed `latency_checkpoint` is nonsense, so non-numeric retained values stay per-call facts that
|
|
8
|
+
* a consumer reads off the entries. Callers must fold only within ONE provider — `usageByProvider` does,
|
|
9
|
+
* by grouping first — since the same key means different things under different protocols.
|
|
10
|
+
*/
|
|
4
11
|
export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
5
12
|
const a = acc ?? { inputTokens: 0, outputTokens: 0 };
|
|
6
13
|
const add = (x: number | undefined, y: number | undefined): number | undefined =>
|
|
@@ -8,29 +15,71 @@ export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
|
8
15
|
return {
|
|
9
16
|
inputTokens: a.inputTokens + next.inputTokens,
|
|
10
17
|
outputTokens: a.outputTokens + next.outputTokens,
|
|
11
|
-
...(((c) => c !== undefined ? { costUsd: c } : {}))(add(a.costUsd, next.costUsd)),
|
|
12
18
|
...(((c) => c !== undefined ? { cacheReadTokens: c } : {}))(add(a.cacheReadTokens, next.cacheReadTokens)),
|
|
13
19
|
...(((c) => c !== undefined ? { cacheCreationTokens: c } : {}))(add(a.cacheCreationTokens, next.cacheCreationTokens)),
|
|
20
|
+
...(((r) => r !== undefined ? { reported: r } : {}))(addReported(a.reported, next.reported)),
|
|
14
21
|
};
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
function addReported(
|
|
25
|
+
a: Record<string, unknown> | undefined,
|
|
26
|
+
b: Record<string, unknown> | undefined,
|
|
27
|
+
): Record<string, unknown> | undefined {
|
|
28
|
+
if (a === undefined) return b;
|
|
29
|
+
if (b === undefined) return a;
|
|
30
|
+
const out: Record<string, unknown> = { ...a };
|
|
31
|
+
for (const [k, v] of Object.entries(b)) {
|
|
32
|
+
const prev = out[k];
|
|
33
|
+
out[k] = typeof v === 'number' && typeof prev === 'number' ? prev + v : v;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* turn
|
|
39
|
+
* Everything a set of messages records about what happened — provider calls and the brackets around
|
|
40
|
+
* them — in message order.
|
|
41
|
+
*
|
|
42
|
+
* Entries are anchored on turn heads and are self-describing (`site`, `traceId`), so this is a flat
|
|
43
|
+
* read with no correlation to do: filter it for whatever question is being asked — one turn
|
|
44
|
+
* (`traceId`), one tool call (`site`), one session (pass the lot) — and the same one fact set answers
|
|
45
|
+
* "what did this tool cost", "what did this user cost" and "how long did that take".
|
|
22
46
|
*/
|
|
23
|
-
export function
|
|
24
|
-
const out =
|
|
47
|
+
export function turnActivity(messages: Iterable<Message>): TurnEntry[] {
|
|
48
|
+
const out: TurnEntry[] = [];
|
|
25
49
|
for (const m of messages) {
|
|
26
|
-
if (m.
|
|
27
|
-
|
|
50
|
+
if (m.activity !== undefined) out.push(...m.activity);
|
|
51
|
+
|
|
52
|
+
// Sessions written before accounting moved onto the turn head: an assistant message's own `usage`
|
|
53
|
+
// object, and a `tool-result`'s `usage` array. Neither shape is written any more.
|
|
54
|
+
const legacyOwn = (m as { usage?: Usage }).usage;
|
|
55
|
+
if (legacyOwn !== undefined && m.providerName !== undefined) {
|
|
56
|
+
out.push({ kind: 'call', provider: m.providerName, usage: legacyOwn, traceId: m.traceId });
|
|
28
57
|
}
|
|
29
58
|
for (const c of m.content) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
59
|
+
const legacy = c.type === 'tool-result' ? (c as { usage?: UsageRecord[] }).usage : undefined;
|
|
60
|
+
if (legacy !== undefined) out.push(...legacy.map((r): TurnEntry => ({ kind: 'call', ...r })));
|
|
33
61
|
}
|
|
34
62
|
}
|
|
35
63
|
return out;
|
|
36
64
|
}
|
|
65
|
+
|
|
66
|
+
/** The provider calls in a set of messages — `turnActivity` less the spans. */
|
|
67
|
+
export function usageEntries(messages: Iterable<Message>): UsageRecord[] {
|
|
68
|
+
return turnActivity(messages).filter((e): e is { kind: 'call' } & UsageRecord => e.kind === 'call');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Aggregate the token accounting on a set of messages, keyed by the provider billed. Pass a single
|
|
73
|
+
* turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
|
|
74
|
+
*
|
|
75
|
+
* Note what this deliberately does NOT do: sum a provider's `reported` fields across providers. The
|
|
76
|
+
* same key means different things under different protocols (`prompt_tokens` includes cache hits,
|
|
77
|
+
* `input_tokens` does not), so only the normalised counters are comparable across a mixed turn.
|
|
78
|
+
*/
|
|
79
|
+
export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
|
|
80
|
+
const out = new Map<string, Usage>();
|
|
81
|
+
for (const r of usageEntries(messages)) {
|
|
82
|
+
out.set(r.provider, addUsage(out.get(r.provider), r.usage));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|