@matatbread/matbot-core 0.4.1 → 0.4.4

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.1",
7
+ "version": "0.4.4",
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.1"
38
+ "@matatbread/matbot-plugin-api": "^0.4.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/index.ts CHANGED
@@ -28,11 +28,13 @@ export {
28
28
  unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
29
29
  createBroadcaster, subscribable, createNotifier, scopedNotifier,
30
30
  installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
31
- contextSwitch, onContextQuiesce, flushIfQuiescent,
32
- installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
31
+ contextSwitch, machineBusy, quiesced, onContextQuiesce, flushIfQuiescent,
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, Quiescer,
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
- * A plugin the loader could not bring up — an incompatible runtime, an import that rejected, a
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 { currentUsageSink } from '@matatbread/matbot-plugin-api/host';
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 opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
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 sink; slice off whatever it appends during *this* call to attribute it here.
491
- const usageSink = currentUsageSink();
492
- const usageMark = usageSink?.length ?? 0;
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: Date.now() - startedAt,
588
+ tool, result, isError, durationMs,
541
589
  });
542
590
 
543
- const callUsage = usageSink ? usageSink.slice(usageMark) : [];
544
- toolResults.push({ type: 'tool-result', id: tc.id, result, isError,
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.
@@ -2,14 +2,14 @@ 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';
9
9
  import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
10
10
  import { appendMessage, createMessage } from './session.js';
11
- import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
12
- import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api/host';
11
+ import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
12
+ import { machineBusy, quiesced, withUsageScope } from '@matatbread/matbot-plugin-api/host';
13
13
  import { runSession } from './runner.js';
14
14
 
15
15
  export interface SessionRunnerDeps {
@@ -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,9 +195,81 @@ 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;
258
+ // Hold the machine for the WHOLE queue, not per turn. A deferred machine mutation (the
259
+ // StorageBackend swap, a plugin's deferred edit of this very session) may only land where no
260
+ // operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
261
+ // committed document back for followup, appends markers to it, rewrites it for a retract, and
262
+ // persists the next turn's user message before that turn opens. All of that sits between turns,
263
+ // and a per-turn hold declared it idle. The queue draining is the real boundary — the same one
264
+ // usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
265
+ // so no early return out of the loop below can leave the machine held.
266
+ //
267
+ // Wait out any deferred work still settling BEFORE holding: a flusher that rewrites this very
268
+ // session (a `session_edit` deferred out of the last turn) must finish before this turn reads its
269
+ // copy, or the read would take the pre-edit document and the write-back below would put it back.
270
+ // The edge is only reachable while nothing holds the machine, so the wait comes first and the
271
+ // hold second — the reverse would deadlock on an edge this pump is itself preventing.
272
+ return quiesced().then(() => machineBusy(async () => {
196
273
  try {
197
274
  while (s.queue.length > 0) {
198
275
  // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
@@ -288,8 +365,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
288
365
  // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
289
366
  // access they trigger) reads it via currentPrincipal(). The consumption MUST happen inside
290
367
  // the callback: an async iterator returned out of the scope would lose it before it pulls.
291
- // contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
292
- // deferred during it lands at this scope's quiescent edge — never mid-CAS.
368
+ // runAs, not a context switch of its own: the pump holds the machine across the whole queue
369
+ // (see above), so a turn declares only its owner. Each item carries its own submitter.
293
370
  // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
294
371
  // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
295
372
  // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
@@ -298,7 +375,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
298
375
  // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
299
376
  // signal-driven aborts and not the policy ones.
300
377
  let terminal: PipelineEvent['type'] | undefined;
301
- await contextSwitch(head.principal, () => withUsageScope(async () => {
378
+ // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
379
+ // definition and a detached classifier settles whenever it settles, so a scope that ended at
380
+ // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
381
+ // The entries array is registered for flush before any work starts, so a late arrival lands
382
+ // in something already tracked and needs no coordination of its own.
383
+ await withUsageScope(async usage => {
384
+ s.pendingUsage.push(usage.entries);
385
+ await runAs(head.principal, async () => {
302
386
  for await (const ev of runSession({
303
387
  session,
304
388
  config: { provider: head.provider, traceId: head.traceId },
@@ -323,7 +407,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
323
407
  if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
324
408
  emit(s, ev);
325
409
  }
326
- }));
410
+ });
327
411
 
328
412
  // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
329
413
  // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
@@ -334,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
334
418
  const committed = await deps.store.get(id);
335
419
  if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
336
420
  let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
337
- await contextSwitch(head.principal, async () => {
421
+ await runAs(head.principal, async () => {
338
422
  followup = await hooks.runFollowup({
339
423
  session: committed,
340
424
  resubmitDepth: head.resubmitDepth,
@@ -439,6 +523,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
439
523
  }
440
524
  }
441
525
  }
526
+ }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
442
527
  } catch (e) {
443
528
  emit(s, { type: 'error', error: String(e), traceId: head.traceId });
444
529
  } finally {
@@ -449,12 +534,18 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
449
534
  s.running = false;
450
535
  s.runningTraceId = undefined;
451
536
  s.replay = [];
537
+ // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
538
+ // is not a well-defined moment to total anything at: steers terminate and resume, a retract
539
+ // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
540
+ // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
541
+ await flushUsage(id, s);
452
542
  // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
453
543
  // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
454
544
  // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
455
545
  notify(s, { type: 'idle', sessionId: id });
456
546
  maybeCleanup(id, s);
457
547
  }
548
+ }));
458
549
  };
459
550
 
460
551
  return {
package/src/session.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Session, Message, MessageContent, MessageRole, Usage } from './types.js';
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
  }
@@ -138,9 +138,22 @@ export class CachingStorageBackend implements StorageBackend {
138
138
  private readonly ttlMs: number | undefined;
139
139
  private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
140
140
 
141
+ /**
142
+ * Forwarded only when the wrapped backend has it. `namespaces` is optional, and its ABSENCE is the
143
+ * signal a caller reads ("this backend cannot be enumerated") — so a decorator that always declared
144
+ * the method would answer for backends that cannot, turning a degradable capability into a runtime
145
+ * failure. Assigned per instance rather than declared as a method so `'namespaces' in backend` stays
146
+ * truthful through the wrapper.
147
+ *
148
+ * Not cached: the cache is per-namespace and populated by reads, so it knows only what has been
149
+ * asked for — never the set of namespaces that exist.
150
+ */
151
+ readonly namespaces?: () => Promise<string[]>;
152
+
141
153
  constructor(inner: StorageBackend, options: CachingOptions = {}) {
142
154
  this.inner = inner;
143
155
  this.ttlMs = options.ttlMs;
156
+ if (inner.namespaces !== undefined) this.namespaces = () => inner.namespaces!();
144
157
  }
145
158
 
146
159
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
@@ -1,2 +1,3 @@
1
1
  export * from './query/index.js';
2
2
  export { CachingStorageBackend, type CachingOptions, type CacheNamespaceStats } from './caching-backend.js';
3
+ export { mediumGuard } from './medium-guard.js';
@@ -0,0 +1,114 @@
1
+ import type { Store, CASResult, StoreQuery, QueryResult } from '@matatbread/matbot-plugin-api';
2
+
3
+ /**
4
+ * Fail a write whose read came from a different storage medium.
5
+ *
6
+ * Exactly one `StorageBackend` is active, nothing is migrated between them, and a swap repoints every
7
+ * store proxy at once. A caller that read a document before the swap and writes it after is therefore
8
+ * addressing two different media with one read-modify-write, and neither end can tell: `cas` asks
9
+ * "did this document change?", which the new backend answers about a document it never issued — most
10
+ * often "there is nothing here", at which point an unconditional `set` writes the old backend's
11
+ * document into the new one and the session has silently migrated. Compare-and-swap cannot see this,
12
+ * because the medium is not what it compares.
13
+ *
14
+ * The fix belongs here rather than at whatever happened to be running: the exposure is not particular
15
+ * to the quiescent edge's deferred work — an HTTP tool call has always been able to straddle a swap
16
+ * the same way — so pushing the check up to every caller would be asking each of them to know
17
+ * something only storage knows.
18
+ *
19
+ * The version is the only token tying a read to its write, so it carries the medium: stamped on the
20
+ * way out, checked and stripped on the way in. Stripping is what keeps it invisible — a caller that
21
+ * writes a document straight back (`store.set(id, { ...doc, title })`, which does not bump the
22
+ * version) must not persist a stamp, or the next read would stamp it twice.
23
+ *
24
+ * An UNstamped version is always accepted: that is a document the caller minted rather than read, and
25
+ * a create has no earlier medium to disagree with.
26
+ */
27
+
28
+ const STAMP = /^m(\d+)~([\s\S]*)$/;
29
+
30
+ interface Stamped { id: string; version: string }
31
+
32
+ function stamp<T extends Stamped>(doc: T, generation: number): T {
33
+ return { ...doc, version: `m${generation}~${doc.version}` };
34
+ }
35
+
36
+ type Checked =
37
+ | { ok: true; version: string }
38
+ | { ok: false; from: number };
39
+
40
+ function check(version: string, generation: number): Checked {
41
+ const m = STAMP.exec(version);
42
+ if (m === null) return { ok: true, version }; // minted, not read
43
+ if (Number(m[1]) === generation) return { ok: true, version: m[2]! };
44
+ return { ok: false, from: Number(m[1]) };
45
+ }
46
+
47
+ /** Strip a stamp wherever one appears, so nothing a caller hands back is persisted with one. */
48
+ function strip<T extends Stamped>(doc: T): T {
49
+ const m = STAMP.exec(doc.version);
50
+ return m === null ? doc : { ...doc, version: m[2]! };
51
+ }
52
+
53
+ /**
54
+ * Wrap `inner` so every document it hands out is stamped with the medium generation it came from, and
55
+ * every write is checked against the generation current at the time of the write. `generation` must
56
+ * increase on every backend swap; the wrapper is placed OUTSIDE the swap proxy, so it is one stable
57
+ * object across every swap and a captured store reference keeps working.
58
+ */
59
+ export function mediumGuard<T extends { id: string; version: string }>(
60
+ inner: Store<T>,
61
+ generation: () => number,
62
+ namespace: string,
63
+ ): Store<T> {
64
+ const stale = (op: string, id: string, from: number): string =>
65
+ `${op} on "${namespace}/${id}" read from storage backend generation ${from}, but generation ${generation()} is now active. ` +
66
+ 'Nothing is migrated between backends, so this write would put a document from the previous one into its replacement. Re-read and retry.';
67
+
68
+ return {
69
+ async get(id) {
70
+ const doc = await inner.get(id);
71
+ return doc === null ? null : stamp(doc, generation());
72
+ },
73
+
74
+ async query(q: StoreQuery): Promise<QueryResult<T>> {
75
+ const page = await inner.query(q);
76
+ const gen = generation();
77
+ return { ...page, items: page.items.map(d => stamp(d, gen)) };
78
+ },
79
+
80
+ async set(id, value) {
81
+ const seen = check(value.version, generation());
82
+ // `set` has no failure channel, and silence is what makes the migration invisible — so it
83
+ // throws. It is reachable only after a swap, holding a document read before it.
84
+ if (!seen.ok) throw new Error(stale('set', id, seen.from));
85
+ await inner.set(id, strip(value));
86
+ },
87
+
88
+ async cas(id, expected, next): Promise<CASResult<T>> {
89
+ const seen = check(expected, generation());
90
+ // Reported as a lost CAS rather than thrown: the caller already has a path for "someone else got
91
+ // there first", and this is that, with the medium in the role of the other writer. `current` is
92
+ // read from the backend now in force — the document the caller must reconcile against.
93
+ if (!seen.ok) {
94
+ console.warn(`[matbot] ${stale('cas', id, seen.from)}`);
95
+ const current = await inner.get(id);
96
+ return { ok: false, current: current === null ? null : stamp(current, generation()) };
97
+ }
98
+ const res = await inner.cas(id, seen.version, strip(next));
99
+ const gen = generation();
100
+ if (res.ok) return { ok: true, doc: stamp(res.doc, gen) };
101
+ return { ok: false, current: res.current === null ? null : stamp(res.current, gen) };
102
+ },
103
+
104
+ async delete(id, expectedVersion) {
105
+ if (expectedVersion === undefined) return inner.delete(id);
106
+ const seen = check(expectedVersion, generation());
107
+ if (!seen.ok) {
108
+ console.warn(`[matbot] ${stale('delete', id, seen.from)}`);
109
+ return false;
110
+ }
111
+ return inner.delete(id, seen.version);
112
+ },
113
+ };
114
+ }
@@ -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
- /** Fold one usage tally into a running total; optional fields are summed only when either side has them. */
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
- * Aggregate the token accounting recorded on a set of messages, keyed by the provider billed: an
19
- * assistant turn's own `Message.usage` (billed to its `providerName`) plus every `tool-result` block's
20
- * `usage` records (each provider-tagged — a tool may run completions against several). Pass a single
21
- * turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
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 usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
24
- const out = new Map<string, Usage>();
47
+ export function turnActivity(messages: Iterable<Message>): TurnEntry[] {
48
+ const out: TurnEntry[] = [];
25
49
  for (const m of messages) {
26
- if (m.usage !== undefined && m.providerName !== undefined) {
27
- out.set(m.providerName, addUsage(out.get(m.providerName), m.usage));
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
- if (c.type === 'tool-result' && c.usage !== undefined) {
31
- for (const r of c.usage) out.set(r.provider, addUsage(out.get(r.provider), r.usage));
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
+ }