summaryrefslogtreecommitdiffhomepage
path: root/.dispatch/wire.reference.md
blob: 40f94cf36271f526be6a51441a0d38bd54de9879 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# `@dispatch/wire` — in-repo reference (read THIS, not node_modules)

> MIRRORS the backend's `@dispatch/wire` package source so headless FE agents can read the wire
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
> **Orchestrator:** SNAPSHOT of `[email protected]` (the metrics types below + the `user-message` turn event
> shipped + version-bumped). Regenerate whenever `@dispatch/wire` changes.
>
> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a
> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`)
> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of
> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join
> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt
> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that
> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below.
>
> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL
> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the
> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate
> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current
> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field
> doc-comments on `TurnMetrics`/`TurnDoneEvent` below.
>
> **0.3.0 changes (token + timing metrics):**
> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted):
>   `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW
>   `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step
>   generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the
>   runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent`
>   gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across
>   steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time).
> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics`
>   — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by
>   `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME
>   `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it
>   for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire.
> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL
>   `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`.
>   Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read
>   `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is
>   UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`).

```ts
/**
 * @dispatch/wire — pure wire types shared by the kernel, the transport
 * contract, and out-of-repo clients (the web frontend).
 *
 * Types ONLY: zero runtime, zero `@dispatch/*` dependencies, so a client can
 * depend on the wire without pulling the kernel runtime.
 */

// ─── Conversation model ─────────────────────────────────────────────────────

/** Who produced a message. */
export type Role = "system" | "user" | "assistant" | "tool";

/** Opaque identifier for a turn (one user→assistant cycle). */
export type TurnId = string & { readonly __brand: "TurnId" };

/**
 * Opaque identifier for a step (one LLM round-trip within a turn). It is the
 * authoritative grouping key for the tool calls a model batches together in a
 * single step (parallel/batched calls): every `tool-call`/`tool-result` event
 * and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the
 * same step carries the SAME `stepId`, so a client groups a batch purely by
 * equality — identically on the live stream and in replayed history. Per-turn
 * unique and gap-free in step order; treat it as opaque (do not parse it). The
 * runtime derives it deterministically from the turn id + 0-based step index.
 */
export type StepId = string & { readonly __brand: "StepId" };

/**
 * A chunk is one ordered piece of a message — the atomic unit of the
 * append-only conversation log. Discriminated by `type`.
 */
export type Chunk =
	| TextChunk
	| ThinkingChunk
	| ToolCallChunk
	| ToolResultChunk
	| ErrorChunk
	| SystemChunk;

/** A piece of plain text content from the assistant or user. */
export interface TextChunk {
	readonly type: "text";
	readonly text: string;
}

/** A piece of model reasoning / thinking content (e.g. extended thinking). */
export interface ThinkingChunk {
	readonly type: "thinking";
	readonly text: string;
}

/**
 * A model's request to run a tool. The kernel routes by `name`; the tool
 * implementation never sees this directly — it receives parsed `input` via
 * `ToolContract.execute`.
 */
export interface ToolCallChunk {
	readonly type: "tool-call";
	readonly toolCallId: string;
	readonly toolName: string;
	readonly input: unknown;
	/**
	 * The step that produced this call — generation provenance stamped by the
	 * runtime when the model emits the call (NOT storage metadata like `seq`,
	 * which is why it lives on the chunk and travels with it through persistence
	 * and replay). Tool calls a model batches together in one step share the same
	 * `stepId`: the grouping key for rendering a parallel batch as one unit, and
	 * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional:
	 * absent on chunks reconstructed outside a turn and on rows persisted before
	 * this field existed, so a consumer must tolerate its absence (render
	 * ungrouped).
	 */
	readonly stepId?: StepId;
}

/**
 * The result of a tool execution, attributed to the originating tool-call id.
 * The kernel guarantees every tool-call chunk gets exactly one result chunk
 * (synthesized if interrupted — see reconcile).
 */
export interface ToolResultChunk {
	readonly type: "tool-result";
	readonly toolCallId: string;
	readonly toolName: string;
	readonly content: string;
	readonly isError: boolean;
	/**
	 * The step that produced the originating call — equal to the `stepId` on the
	 * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result`
	 * AgentEvent, so a consumer groups a step's calls with their results.
	 * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`).
	 * Optional for the same reasons; `reconcile` copies it from the originating
	 * call onto a synthesized (interrupted) result.
	 */
	readonly stepId?: StepId;
}

/** An error that occurred during generation or tool dispatch. */
export interface ErrorChunk {
	readonly type: "error";
	readonly message: string;
	readonly code?: string;
}

/**
 * A system-injected message (e.g. system prompt, context assembly output).
 * Kept distinct from text so the log records provenance.
 */
export interface SystemChunk {
	readonly type: "system";
	readonly text: string;
}

/**
 * A chat message: a role plus an ordered sequence of chunks. Messages are the
 * unit passed to and from the provider; chunks are the unit persisted and
 * rendered.
 */
export interface ChatMessage {
	readonly role: Role;
	readonly chunks: readonly Chunk[];
}

/**
 * A persisted chunk plus its sync metadata. The append-only conversation log
 * stamps every chunk with a monotonic, gap-free, per-conversation `seq` (the
 * sync cursor, assigned in append order) and records the `role` of the message
 * it belongs to. This makes a flat seq-ordered stream both incrementally
 * syncable ("give me chunks after seq N") and regroupable into messages by the
 * client. `chunk` is the content unit — `Chunk` carries no storage/sync cursor
 * (`seq` lives here on the envelope, not on the chunk, since it is assigned by
 * the store and the provider has no use for it). A chunk MAY still carry
 * generation provenance assigned at production time (e.g. a tool chunk's
 * `stepId`), which is intrinsic to the content and so travels with it.
 */
export interface StoredChunk {
	readonly seq: number;
	readonly role: Role;
	readonly chunk: Chunk;
}

// ─── Usage ──────────────────────────────────────────────────────────────────

/**
 * Token usage counters for a single step. All fields are counts of tokens.
 * Cache fields are optional because not all providers expose cache metrics.
 */
export interface Usage {
	readonly inputTokens: number;
	readonly outputTokens: number;
	readonly cacheReadTokens?: number;
	readonly cacheWriteTokens?: number;
}

// ─── Persisted metrics ───────────────────────────────────────────────────────

/**
 * Durable per-step metrics for a completed step — the persisted, replayable
 * counterpart of the live `usage` + `step-complete` events. Combines the step's
 * token usage with its generation timing so a client reopening a past
 * conversation renders the same per-step token/latency breakdown it would have
 * seen live. Built from the turn's events, stored by `conversation-store`, and
 * served by `GET /conversations/:id/metrics`.
 */
export interface StepMetrics {
	readonly stepId: StepId;
	/** The step's token usage (all four counters; cache fields optional per `Usage`). */
	readonly usage: Usage;
	/** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */
	readonly ttftMs?: number;
	/** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */
	readonly decodeMs?: number;
	/** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */
	readonly genTotalMs?: number;
}

/**
 * Durable per-turn metrics for a completed (sealed) turn — the persisted,
 * replayable counterpart of the live `done` event's aggregate `usage` +
 * `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
 * steps; `steps` carries each step's `StepMetrics` in step order. Stored by
 * `conversation-store` keyed by `turnId` and served by
 * `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
 * on every `AgentEvent`, the join key to the live stream.)
 */
export interface TurnMetrics {
	readonly turnId: string;
	/** Aggregate token usage across all steps in the turn. */
	readonly usage: Usage;
	/** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */
	readonly durationMs?: number;
	/** Per-step metrics in step order. */
	readonly steps: readonly StepMetrics[];
	/**
	 * **Context size** — tokens the conversation occupies as of this turn: the
	 * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`),
	 * NOT the aggregate `usage` (which sums per-step prompts and overcounts a
	 * multi-step turn). The persisted, replayable counterpart of
	 * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client
	 * reopening a past conversation reads the LAST turn's `contextSize` as the
	 * current context usage. Optional: absent when no per-step usage was available.
	 */
	readonly contextSize?: number;
}

// ─── Outward events ─────────────────────────────────────────────────────────

/**
 * The union of all events the runtime emits outward during a turn.
 * Consumers (transport, persistence, notifications) pattern-match on `type`.
 */
export type AgentEvent =
	| StatusEvent
	| TurnStartEvent
	| TurnInputEvent
	| TurnTextDeltaEvent
	| TurnReasoningDeltaEvent
	| TurnToolCallEvent
	| TurnToolResultEvent
	| TurnToolOutputEvent
	| TurnUsageEvent
	| TurnStepCompleteEvent
	| TurnErrorEvent
	| TurnDoneEvent
	| TurnSealedEvent;

/** Status change for a conversation (e.g. idle → running). */
export interface StatusEvent {
	readonly type: "status";
	readonly conversationId: string;
	readonly status: string;
}

/** A turn has begun. */
export interface TurnStartEvent {
	readonly type: "turn-start";
	readonly conversationId: string;
	readonly turnId: string;
}

/**
 * The user prompt that opened this turn, surfaced INTO the turn's outward event
 * stream so a WATCHER (subscribed but not the sender) can render the prompt
 * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE
 * as the FIRST event of the turn (before `turn-start`); buffered + replayed to
 * every subscriber (live + late-join). The sender echoes its own prompt
 * optimistically, so DE-DUP against that (by text); a pure watcher renders it
 * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it
 * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.)
 */
export interface TurnInputEvent {
	readonly type: "user-message";
	readonly conversationId: string;
	readonly turnId: string;
	readonly text: string;
}

/** Incremental text content from the model during a turn. */
export interface TurnTextDeltaEvent {
	readonly type: "text-delta";
	readonly conversationId: string;
	readonly turnId: string;
	readonly delta: string;
}

/** Incremental reasoning / thinking content during a turn. */
export interface TurnReasoningDeltaEvent {
	readonly type: "reasoning-delta";
	readonly conversationId: string;
	readonly turnId: string;
	readonly delta: string;
}

/** The model has requested a tool to be run. */
export interface TurnToolCallEvent {
	readonly type: "tool-call";
	readonly conversationId: string;
	readonly turnId: string;
	/**
	 * The step that produced this call. Tool calls a model batches together in
	 * one step share the same `stepId` — the grouping key for rendering a
	 * parallel batch as one unit. Matches the `stepId` on the matching
	 * `tool-result` event and on the persisted tool chunk
	 * (`StoredChunk.chunk.stepId`).
	 */
	readonly stepId: StepId;
	readonly toolCallId: string;
	readonly toolName: string;
	readonly input: unknown;
}

/** A tool has completed execution. */
export interface TurnToolResultEvent {
	readonly type: "tool-result";
	readonly conversationId: string;
	readonly turnId: string;
	/**
	 * The step that produced the originating call. Equal to the `stepId` on the
	 * matching `tool-call` event (same `toolCallId`) and on the persisted tool
	 * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with
	 * their results.
	 */
	readonly stepId: StepId;
	readonly toolCallId: string;
	readonly toolName: string;
	readonly content: string;
	readonly isError: boolean;
	/**
	 * How long the tool took to execute (dispatch → result), in milliseconds —
	 * the backend's authoritative execution time, distinct from any client-side
	 * wall-clock. Optional: present only when the runtime was given a clock.
	 */
	readonly durationMs?: number;
}

/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
export interface TurnToolOutputEvent {
	readonly type: "tool-output";
	readonly conversationId: string;
	readonly turnId: string;
	readonly toolCallId: string;
	readonly data: string;
	readonly stream: "stdout" | "stderr";
}

/** Token usage for the current step or turn. */
export interface TurnUsageEvent {
	readonly type: "usage";
	readonly conversationId: string;
	readonly turnId: string;
	/**
	 * The step this usage report belongs to, so a consumer can attribute tokens
	 * per step (and join with the matching `step-complete` timing by `stepId`).
	 * Optional: absent when the runtime had no step context, and on usage emitted
	 * before this field existed.
	 */
	readonly stepId?: StepId;
	readonly usage: Usage;
}

/**
 * A step (one LLM round-trip) has completed — the authoritative per-step metrics
 * packet, emitted once at the step's end (after the generation stream finishes),
 * so its timing is final (unlike `usage`, which may arrive mid-stream). Carries
 * the step's generation timing; join to the step's tokens via `stepId` on the
 * `usage` event. All timing fields are optional: present only when the runtime
 * was given a clock, and `ttftMs`/`decodeMs` additionally require that a first
 * content token (text or reasoning) was observed this step.
 */
export interface TurnStepCompleteEvent {
	readonly type: "step-complete";
	readonly conversationId: string;
	readonly turnId: string;
	readonly stepId: StepId;
	/** Time to first token: stream start → first text/reasoning delta. */
	readonly ttftMs?: number;
	/** Decode time: first token → stream end (generation total − TTFT). */
	readonly decodeMs?: number;
	/**
	 * Total generation time for the step: stream start → stream end. Present
	 * whenever a clock was available, even if no first token was seen (in which
	 * case `ttftMs`/`decodeMs` are absent). When a first token was seen,
	 * `genTotalMs === ttftMs + decodeMs`.
	 */
	readonly genTotalMs?: number;
}

/** An error occurred during the turn. */
export interface TurnErrorEvent {
	readonly type: "error";
	readonly conversationId: string;
	readonly turnId: string;
	readonly message: string;
	readonly code?: string;
}

/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
	readonly type: "done";
	readonly conversationId: string;
	readonly turnId: string;
	readonly reason: string;
	/**
	 * Total wall-clock duration of the turn (turn start → turn end), in
	 * milliseconds. Optional: present only when the runtime was given a clock.
	 */
	readonly durationMs?: number;
	/**
	 * Aggregate token usage across all steps in the turn — a convenience total so
	 * a consumer need not sum the per-step `usage` events. Optional (absent if the
	 * provider reported no usage).
	 */
	readonly usage?: Usage;
	/**
	 * **Context size** — tokens the conversation occupies right now: the turn's
	 * FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM
	 * round-trip plus that round-trip's output). This is the "tokens in context"
	 * figure a client renders as the chat's current context usage, and a client
	 * treats the LATEST turn's value as the live total.
	 *
	 * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
	 * `inputTokens`, which overcounts a multi-step / tool-calling turn because every
	 * step re-prefills the growing prompt — the final step's input already includes
	 * all prior context, so its input+output is the true occupancy. Optional: absent
	 * when no per-step usage was observed this turn (mirrors `usage`). A later field
	 * will carry the model's max context-window LIMIT; this is only the current size.
	 */
	readonly contextSize?: number;
}

/**
 * The turn has been sealed — all chunks persisted, history is final.
 * This is the hook point for post-turn extensions (compaction, cache-warm).
 */
export interface TurnSealedEvent {
	readonly type: "turn-sealed";
	readonly conversationId: string;
	readonly turnId: string;
}
```